Skip to content

feat: migrates to nixy from nix flakes#40

Merged
nxtcoder17 merged 6 commits intomasterfrom
refactor
Feb 9, 2026
Merged

feat: migrates to nixy from nix flakes#40
nxtcoder17 merged 6 commits intomasterfrom
refactor

Conversation

@nxtcoder17
Copy link
Copy Markdown
Owner

@nxtcoder17 nxtcoder17 commented Feb 8, 2026

Summary by Sourcery

Refactor runfile execution into a new resolver/executor architecture, simplify the CLI command handling, and migrate environment/tooling configuration from Nix flakes to nixy while updating logging and error handling.

New Features:

  • Introduce a resolver-based Runfile loader and task runner that supports includes, dotenv resolution, shell aliases, interactive tasks, and nested task execution.
  • Add a generic executor pipeline with support for sequential/parallel steps and interactive shell commands via PTY.
  • Extend Runfile specification with a formal YAML schema, enhanced watch configuration, and richer examples including interactive Node tasks.
  • Add a new init CLI command placeholder for creating Runfiles and a shell completion listing command for supported shells.

Bug Fixes:

  • Fix prefixed logging so prefixes and dimmed text render correctly across ANSI and non-ANSI terminals, and serialize concurrent writes safely.
  • Improve runfile discovery errors with a reusable helper and a shared ErrRunfileNotFound sentinel error.

Enhancements:

  • Simplify CLI task execution path to a single RunTask helper and streamline fastlog configuration with slog integration.
  • Replace the internal errors package with the shared go.errors library and update error construction and reporting across the codebase.
  • Refine task watch behavior to use the new executor pipeline and file watcher, and narrow test commands to new resolver/executor packages.
  • Adjust fish shell completions and command prefixes formatting for clearer, more consistent UX.

Build:

  • Switch dependency management to use nixy via nixy.yml, removing Nix flake files, and bump Go module dependencies including fastlog, go.errors, term, yaml.v3, and related tooling.

Tests:

  • Add comprehensive resolver tests covering command parsing, step creation, environment resolution, dotenv loading, and env expression evaluation.

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Feb 8, 2026

Reviewer's Guide

Refactors task execution around a new YAML-based resolver and executor pipeline, simplifies the CLI to run a single task with improved logging and error handling, updates shell completion and examples, and migrates tooling/dependencies including switching from Nix flakes to nixy and adopting the shared go.errors library.

Sequence diagram for running a task via the new resolver and executor pipeline

sequenceDiagram
    actor User
    participant CLI as cmd_run_main
    participant RF as runfile_RunTask
    participant Res as resolver_Resolver
    participant Exec as executor_Pipeline
    participant W as watcher_Watcher

    User->>CLI: run <task> [--env K=V]
    CLI->>CLI: locateRunfile / getRunfilePath
    CLI->>RF: RunTask(ctx, runfilePath, task, kv)
    RF->>Res: Load(ctx, runfilePath)
    Res-->>RF: Resolver{Env,Tasks}

    RF->>Res: RunTask(ctx, task)
    Res->>Res: GetTask(name)
    Res-->>Res: ResolvedTask
    Res->>Res: createSteps(ResolvedTask)
    Res-->>RF: []Step

    RF->>Exec: NewPipeline(logger, steps)
    alt task.Watch disabled
        RF->>Exec: Start(ctx)
        Exec->>Exec: execStep/execCommands
        Exec-->>RF: error or nil
    else task.Watch enabled
        RF->>W: NewWatcher(ctx, watchSpec)
        RF->>Exec: Start(ctx) (in goroutine)
        W->>W: Watch(ctx)
        loop on file change
            W-->>Exec: events (via channel)
            Exec->>Exec: rerun affected steps
        end
        W-->>RF: watcher closed
    end

    RF-->>CLI: error or nil
    alt error is *errors.Error
        CLI->>CLI: slog.Error("failed to run task", err.AsKeyValues())
    else generic error
        CLI->>CLI: slog.Error("failed to run task", "err", err)
    end
    CLI-->>User: exit status
Loading

Class diagram for the new resolver and executor types

classDiagram
    class Resolver {
        +map~string,string~ Env
        +map~string,extendedTaskSpec~ Tasks
        +RunTask(ctx context.Context, name string) error
        +GetTask(name string) *ResolvedTask, error
    }

    class extendedTaskSpec {
        +TaskSpec TaskSpec
        +bool IsImported
        +string ImportPrefix
    }

    class ResolvedTask {
        +string Name
        +string Dir
        +[]string Shell
        +map~string,any~ Env
        +bool Parallel
        +bool Interactive
        +bool Silent
        +TaskWatchSpec* Watch
        +[]*Command Commands
    }

    class Command {
        +string Text
        +bool IsRunTarget
        +map~string,any~ Env
    }

    class TaskContext {
        +context.Context Context
        +string PWD
        +map~string,string~ Env
    }

    class Step {
        +[]Step SubSteps
        +[]Command Commands
        +bool Parallel
    }

    class Pipeline {
        -*slog.Logger logger
        -sync.Mutex mu
        -func() cancel
        -[]Step steps
        +NewPipeline(logger *slog.Logger, steps []Step) *Pipeline
        +Start(parent context.Context) error
        +Stop() error
        -execStep(ctx context.Context, step *Step) error
        -execCommands(ctx context.Context, step *Step) error
        -execSubSteps(ctx context.Context, step *Step) error
    }

    class CommandInterface {
        <<interface>>
        +Run(ctx context.Context) error
    }

    class command {
        -func(context.Context) error run
        -[]func(context.Context) error preHooks
        -[]func(context.Context) error postHooks
        +Run(ctx context.Context) error
        +AddPreHook(h func(context.Context) error) *command
        +AddPostHook(h func(context.Context) error) *command
    }

    class ExecutorShellHelpers {
        +NewShellCommand(handler func(context.Context)*exec.Cmd) *command
        +NewInteractiveShellCommand(handler func(context.Context)*exec.Cmd) *command
    }

    class RunfileFacade {
        +RunTask(ctx context.Context, runfile string, task string, envOverrides map~string,string~) error
    }

    class EnvResolver {
        +parseDotEnvFilesInto(store map~string,string~, files []string) error
        +parseEnvInto(ctx context.Context, envStore map~string,string~, envMap map~string,any~) error
    }

    class ShellResolver {
        +ParseShell(shell any) []string, error
        -map~string,[]string~ shellAliasMap
    }

    class RunCLI {
        +main()
        +getRunfilePath(dir string) (string, error)
        +locateRunfile(c *cli.Command) (string, error)
    }

    %% Relationships
    Resolver "1" --> "*" ResolvedTask : creates
    Resolver "1" o--> "*" extendedTaskSpec : Tasks
    ResolvedTask "1" --> "*" Command : Commands

    Pipeline "1" o--> "*" Step : steps
    Step "*" o--> "*" Step : SubSteps
    Step "*" --> "*" CommandInterface : Commands

    command ..|> CommandInterface
    ExecutorShellHelpers ..> command : constructs

    RunfileFacade ..> Resolver : uses
    RunfileFacade ..> Pipeline : constructs

    Resolver ..> EnvResolver : uses
    Resolver ..> ShellResolver : uses

    RunCLI ..> RunfileFacade : calls RunTask
    RunCLI ..> RunCLI : getRunfilePath/locateRunfile
Loading

File-Level Changes

Change Details Files
Refactor CLI entrypoint to use the new RunTask API, simplify flags, and improve runfile discovery/error handling.
  • Replace custom fastlog configuration with the new functional options API and wire it into slog as the default logger.
  • Remove support for parallel/watch flags at the CLI level and delegate task execution to runfile.RunTask with a single task argument and env overrides.
  • Add a top-level ErrRunfileNotFound sentinel and a reusable getRunfilePath helper, using errors.Is to distinguish not-found from other filesystem errors.
  • Adjust runfile location logic to reuse getRunfilePath when walking parent directories and return ErrRunfileNotFound when nothing is found.
  • Change error reporting on command failure to unwrap go.errors.Error using AsKeyValues for structured logging and standard errors otherwise.
  • Add a stub run init command that detects an existing Runfile in the current directory and logs a not-yet-implemented message.
  • Rework the shell:completion command to accept an optional shell argument, list supported shells when none is given, and disable CLI-driven shell completion generation.
  • Comment out the dynamic task-based completion generation path and associated code in completions.go, leaving it as a reference only.
cmd/run/main.go
cmd/run/completions.go
Introduce a resolver-based Runfile model with YAML specs, includes, dotenv support, and a higher-level RunTask orchestration API.
  • Define a RunfileSpec/TaskSpec/TaskWatchSpec/CommandObject schema for YAML Runfiles, including includes, dotenv files, env expressions, and watch configuration.
  • Implement a Resolver that loads a Runfile from disk, resolves relative paths (including includes and dotenv paths) against the appropriate directory, merges env and tasks across includes, and produces a normalized task map.
  • Add env parsing helpers that load dotenv files into an env store, support required flags, shell-evaluated values via sh/bash and aliasing, and respect OS/env-store precedence.
  • Implement shell parsing with an alias map (sh, bash, zsh, node, python, etc.) that normalizes shell specifications from either a string alias or explicit list and validates unsupported formats.
  • Add a high-level RunTask(order) function that loads the Resolver for a given Runfile path, applies env overrides, and dispatches to Resolver.RunTask.
  • Provide unit tests for command parsing, step creation, and env/dotenv handling to cover happy paths and error scenarios for the resolver layer.
pkg/runfile/run.go
pkg/runfile/spec/spec.go
pkg/runfile/resolver/resolver.go
pkg/runfile/resolver/task.go
pkg/runfile/resolver/task_test.go
pkg/runfile/resolver/env.go
pkg/runfile/resolver/env_test.go
pkg/runfile/resolver/shell.go
Add an executor pipeline with composable commands and interactive PTY-based shell support.
  • Introduce a Command interface with pre/post hooks and a concrete command implementation for ergonomic construction and extension.
  • Implement a Pipeline that runs a sequence of Steps, each containing either substeps or commands, with optional parallel execution of both commands and substeps using errgroup.
  • Provide NewShellCommand to run shell commands under context control, setting process groups and sending SIGTERM/SIGKILL on cancellation for clean shutdowns.
  • Provide NewInteractiveShellCommand that runs commands under a PTY, wiring stdin/stdout, handling terminal resize signals, and placing stdin in raw mode for full interactive behavior.
  • Connect the resolver’s createSteps logic to the executor by generating Steps, configuring env and working directories, and using pre-hooks to pretty-print the command that is about to run unless the task is silent/interactive.
pkg/executor/command.go
pkg/executor/pipeline.go
pkg/executor/shell-command.go
pkg/runfile/resolver/task.go
Standardize error handling on the shared go.errors library and extend domain-specific error helpers.
  • Replace imports of the local pkg/errors implementation with github.com/nxtcoder17/go.errors where appropriate, especially in the CLI entrypoint.
  • Rewrite error helper functions in pkg/errors/constants.go to construct go.errors.Error values with Wrap and KV, including new helpers for invalid shell aliases, circular dependencies, task failure/requirements, and env/dotenv issues.
  • Adjust CLI error handling to detect *errors.Error and log its key-values via AsKeyValues instead of the previous SlogAttrs-based logging.
cmd/run/main.go
pkg/errors/constants.go
Improve logging prefix rendering and shell completion behavior, and update example Runfiles and project tasks to match the new model.
  • Fix PrefixedWriter so that prefixes and rendered content are written separately, preventing ANSI styling from affecting subsequent text and ensuring consistent behavior in non-ANSI terminals.
  • Change LogWriter to expose its mutex as Mu, and update WithPrefix/WithDimmedPrefix to format prefixes differently for ANSI vs non-ANSI terminals (styled brackets for ANSI, plain [prefix] for others) and avoid dimming in non-ANSI environments.
  • Adjust color helpers to remove trailing spaces in styled prefixes and let writer methods control spacing, ensuring predictable prefix formatting across all styles.
  • Rewrite fish completion definitions to stop dynamically completing task names and instead provide basic flag completions with a simple “no subcommand yet” test.
  • Update the root Runfile and examples/Runfile.yml to demonstrate the new resolver features (env overrides, dotenv/shell expressions, interactive node task), tweak defaults (e.g., CGO_ENABLED=0), and adjust test tasks to target resolver/executor packages instead of the removed parser/runfile packages.
pkg/writer/writer.go
pkg/writer/colors.go
cmd/run/completions/run.fish
examples/Runfile.yml
Runfile.yml
Migrate project tooling to nixy and refresh module dependencies.
  • Introduce nixy.yml with pinned nixpkgs revision, Go-related tools, and onShellEnter bootstrap commands, while removing flake.nix, flake.lock, and .envrc.
  • Refresh go.mod/go.sum to update fastlog, term, sync, and sys versions, add new dependencies for go.errors, chroma, lipgloss, termenv, pty, yaml.v3, and remove unused packages like the old parser/test support.
  • Align tests and build tasks in Runfile.yml with the new package layout (resolver and executor) and remove references to deleted runfile parsing code.
nixy.yml
.envrc
flake.nix
flake.lock
go.mod
go.sum
Runfile.yml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 security issue, 8 other issues, and left some high level feedback:

Security issues:

  • Detected non-static command inside Command. Audit the input to 'exec.Command'. If unverified user data can reach this call site, this is a code injection vulnerability. A malicious actor can inject a malicious script to execute arbitrary code. (link)

General comments:

  • The locateRunfile loop now uses getRunfilePath but only updates dir/oldDir on non-ErrRunfileNotFound errors, so when ErrRunfileNotFound is returned you never advance the directory and can spin in an infinite loop; consider always updating oldDir/dir before the next iteration.
  • ErrCircularDependency currently constructs an error with the message "invalid shell alias", which looks like a copy-paste mistake; it should probably have a circular-dependency-specific message.
  • In resolver.RunTask you call pipeline.Start(ctx) once in a goroutine and again synchronously later, which will execute the pipeline twice concurrently; refactor so the pipeline is started exactly once and the watcher just reacts to its lifecycle.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `locateRunfile` loop now uses `getRunfilePath` but only updates `dir`/`oldDir` on non-`ErrRunfileNotFound` errors, so when `ErrRunfileNotFound` is returned you never advance the directory and can spin in an infinite loop; consider always updating `oldDir`/`dir` before the next iteration.
- `ErrCircularDependency` currently constructs an error with the message `"invalid shell alias"`, which looks like a copy-paste mistake; it should probably have a circular-dependency-specific message.
- In `resolver.RunTask` you call `pipeline.Start(ctx)` once in a goroutine and again synchronously later, which will execute the pipeline twice concurrently; refactor so the pipeline is started exactly once and the watcher just reacts to its lifecycle.

## Individual Comments

### Comment 1
<location> `cmd/run/main.go:219` </location>
<code_context>
-				if err2, ok := err.(*errors.Error); ok {
-					logger.Error(err2.Error(), err2.SlogAttrs()...)
-				}
+			if err := runfile.RunTask(ctx, runfilePath, args[0], kv); err != nil {
+				return err
 			}
</code_context>

<issue_to_address>
**question (bug_risk):** New RunTask call ignores parallel and watch flags that were previously supported

The previous code passed a `RunOption` struct into `rf.Run`, which carried `--parallel`, `--watch`, and other flags. The new `runfile.RunTask` call no longer receives these options, so those flags are now ignored. If this behavior change isn’t intentional, please extend `RunTask` (or add a wrapper) so it accepts and honors the same execution options.
</issue_to_address>

### Comment 2
<location> `pkg/runfile/resolver/env.go:19-24` </location>
<code_context>
+)
+
+func parseDotEnvFilesInto(store map[string]string, files []string) error {
+	for _, f := range files {
+		if !filepath.IsAbs(f) {
+			return errors.New("dotenv file must have absolute paths").KV("dotenv.file", f)
+		}
+
+		f, err := os.Open(f)
+		if err != nil {
+			return errors.New("failed to open dotenv file").Wrap(err).KV("dotenv.file", f)
</code_context>

<issue_to_address>
**issue:** Variable shadowing in parseDotEnvFilesInto makes error metadata misleading

Inside `parseDotEnvFilesInto`, the `f` from `os.Open(f)` shadows the filename:

```go
for _, f := range files {
    if !filepath.IsAbs(f) {
        return errors.New("dotenv file must have absolute paths").KV("dotenv.file", f)
    }

    f, err := os.Open(f)
    if err != nil {
        return errors.New("failed to open dotenv file").Wrap(err).KV("dotenv.file", f)
    }
}
```

At the `KV("dotenv.file", f)` call in the error path, `f` is an `*os.File`, so the metadata won’t contain the path. Renaming the file handle (e.g. `fh`) will preserve the actual filename in the error context.
</issue_to_address>

### Comment 3
<location> `pkg/runfile/resolver/task.go:130-139` </location>
<code_context>
+		return err
+	}
+
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		<-ctx.Done()
+		// ctx.Logger().Info("fwatcher is closing ...")
+		watch.Close()
+	}()
+
+	// executors := []executor.Executor{pipeline}
+	//
+	// if rt.Watch.SSE != nil && rt.Watch.SSE.Addr != "" {
+	// 	executors = append(executors, executor.NewSSEExecutor(executor.SSEExecutorArgs{Addr: rt.Watch.SSE.Addr}))
+	// }
+
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		if err := pipeline.Start(ctx); err != nil {
+			slog.Error("starting command", "err", err)
+		}
+		slog.Debug("final executor start finished")
+	}()
+
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		<-ctx.Done()
+		pipeline.Stop()
+		slog.Debug("2. context cancelled")
+	}()
+
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		watch.Watch(ctx)
+		slog.Debug("3. watcher closed")
+	}()
+
+	pipeline.Start(ctx)
+
+	counter := 0
</code_context>

<issue_to_address>
**issue (bug_risk):** RunTask starts the pipeline twice: once in a goroutine and once synchronously

`Resolver.RunTask` calls `pipeline.Start(ctx)` both in a goroutine and again at the end of the function, so the same pipeline may run concurrently and the second call’s error is dropped. Start the pipeline in exactly one place (either in the goroutine that coordinates with the watcher or synchronously before setting up the watcher) and make sure the returned error is handled in a single, consistent path.
</issue_to_address>

### Comment 4
<location> `cmd/run/completions/run.fish:29` </location>
<code_context>
-complete -c $PROGNAME -l help -s h -d 'show help'
-complete -c $PROGNAME -l list -s l -d 'list all tasks'
-complete -c $PROGNAME -rF -l file -s f -d 'runs targets from this runfile' 
+complete -c completion -n '__fish_completion_no_subcommand' -f -l help -s h -d 'show help'
+complete -c completion -n '__fish_completion_no_subcommand' -f -l help -s h -d 'show help'

</code_context>

<issue_to_address>
**issue (bug_risk):** Fish completion uses `completion` as the command name instead of `run`

Both new `complete` lines target `completion` instead of the actual CLI (`run` or the real binary name), so fish completions likely won’t work as intended. Also, the two lines are identical; please remove the duplicate and point the remaining one at the correct command name.
</issue_to_address>

### Comment 5
<location> `Runfile.yml:43-45` </location>
<code_context>
+        go test -json ./pkg/runfile/resolver/... $pattern_args | gotestfmt $testfmt_args
+        go test -json ./pkg/executor/... $pattern_args | gotestfmt $testfmt_args

   test:only-failing:
     cmd:
-      - go test -json ./pkg/runfile | gotestfmt --hide successful-tests
+      - go test -json ./pkg/runfile/resolver/... | gotestfmt --hide successful-tests
+        go test -json ./pkg/executor/... $pattern_args | gotestfmt --hide successful-tests
+
</code_context>

<issue_to_address>
**issue (bug_risk):** Second go test command in `test:only-failing` is missing a list item marker

In `test:only-failing`, the second `go test` line is missing a leading `-`, so YAML will treat it as part of the first command rather than a separate list item. If you intend to run both commands, it should be:

```yaml
  test:only-failing:
    cmd:
      - go test -json ./pkg/runfile/resolver/... | gotestfmt --hide successful-tests
      - go test -json ./pkg/executor/... $pattern_args | gotestfmt --hide successful-tests
```
</issue_to_address>

### Comment 6
<location> `pkg/runfile/resolver/task_test.go:486-490` </location>
<code_context>
+	}
+}
+
+// Helper to check if a step uses the correct executor type
+func isInteractiveStep(step executor.Step) bool {
+	// This is a basic check - in real tests you might want to
+	// use reflection or add a method to identify command types
+	return len(step.Commands) > 0
+}
</code_context>

<issue_to_address>
**nitpick (testing):** Unused helper `isInteractiveStep` suggests tests might be incomplete for distinguishing interactive vs non-interactive commands

This helper isn’t used and its current implementation (`len(step.Commands) > 0`) doesn’t distinguish interactive vs non‑interactive commands, so it isn’t validating the intended behavior. I recommend either removing it, or wiring it into tests that explicitly assert interactive tasks use `NewInteractiveShellCommand` and non‑interactive ones use `NewShellCommand` (e.g., via a test‑only marker/interface on the command type).
</issue_to_address>

### Comment 7
<location> `pkg/runfile/resolver/env_test.go:11` </location>
<code_context>
+	"testing"
+)
+
+func TestParseDotEnvFilesInto(t *testing.T) {
+	tests := []struct {
+		name           string
</code_context>

<issue_to_address>
**suggestion (testing):** ParseDotEnvFilesInto tests don’t cover invalid dotenv file contents

Current cases cover empty lists, relative paths, missing files, and key overrides, but not the error path from `godotenv.Parse`.

Please add a test that:
- Creates a `.env` file with invalid syntax (e.g., `INVALID LINE WITHOUT EQUALS`),
- Passes its absolute path to `parseDotEnvFilesInto`, and
- Asserts an error is returned, ideally checking that it reflects a parse failure rather than just file I/O.

This will verify behavior when users provide malformed `.env` files.

Suggested implementation:

```golang
import (
	"context"
	"maps"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

```

```golang
	}{
		{
			name:         "when files list is empty, it must pass",
		},
		{
			name: "when dotenv file has invalid syntax, it must return a parse error",
			setupFiles: map[string]string{
				// invalid dotenv syntax: missing '=' separator
				".env": "INVALID LINE WITHOUT EQUALS",
			},
			// This filename will be converted to an absolute path in the test setup;
			// we do not use relative/non-existent path flags so that we exercise
			// the parse error path instead of I/O failures.
			files:        []string{".env"},
			initialStore: map[string]string{},
			// No variables are expected to be loaded on parse failure.
			expected:       map[string]string{},
			wantErr:        true,
			useRelPath:     false,
			useNonExistent: false,
		},

```

To fully satisfy the "ideally checking that it reflects a parse failure" part of your comment, you should also update the test loop in `TestParseDotEnvFilesInto` (the part that calls `parseDotEnvFilesInto`) to special-case this new scenario and assert on the error message, for example:

1. After calling `err := parseDotEnvFilesInto(ctx, store, files...)` and before the generic `wantErr` checks, add logic such as:

```go
if tt.name == "when dotenv file has invalid syntax, it must return a parse error" {
    if err == nil {
        t.Fatalf("expected parse error, got nil")
    }
    if !strings.Contains(err.Error(), "parse") && !strings.Contains(err.Error(), "invalid line") {
        t.Fatalf("expected parse-related error, got %q", err)
    }
}
```

2. Adjust variable names (`err`, `tt`, etc.) to match the existing test body. Ensure this check runs only for this specific test case so other tests remain unchanged.
</issue_to_address>

### Comment 8
<location> `pkg/runfile/resolver/task.go:92` </location>
<code_context>
+	}, nil
+}
+
+func (r *Resolver) RunTask(ctx context.Context, name string) error {
+	rt, err := r.GetTask(name)
+	if err != nil {
</code_context>

<issue_to_address>
**issue (complexity):** Consider refactoring `RunTask`, `createSteps`, and the printing helpers into smaller focused helpers and interfaces to simplify orchestration, recursion, and UI concerns in this resolver.

You can reduce complexity meaningfully here without changing behavior by:

---

### 1. Untangle `RunTask` orchestration and watcher lifecycle

`RunTask` currently:

- Builds steps and pipeline
- Handles watch creation
- Manages multiple goroutines + `WaitGroup`
- Calls `pipeline.Start(ctx)` more than once
- Contains commented‑out experimental code

You can push the concurrency + watch wiring into a helper and keep a single `Start` call:

```go
func (r *Resolver) RunTask(ctx context.Context, name string) error {
	rt, err := r.GetTask(name)
	if err != nil {
		return err
	}

	lw := &writer.LogWriter{Writer: os.Stderr}
	steps, err := r.createSteps(rt, createCommandGroupArgs{
		Stdout: lw,
		Stderr: lw,
	})
	if err != nil {
		return err
	}

	pipeline := executor.NewPipeline(slog.Default(), steps)

	if rt.Watch == nil || !rt.Watch.Enabled {
		return pipeline.Start(ctx)
	}

	return runWithWatch(ctx, rt, pipeline)
}
```

Then put all the goroutine/`WaitGroup` handling into a dedicated helper:

```go
func runWithWatch(ctx context.Context, rt *ResolvedTask, pipeline *executor.Pipeline) error {
	watch, err := watcher.NewWatcher(ctx, watcher.WatcherArgs{
		WatchDirs:            rt.Watch.Dirs,
		IgnoreDirs:           rt.Watch.IgnoreDirs,
		WatchExtensions:      rt.Watch.Extensions,
		IgnoreExtensions:     rt.Watch.IgnoreExtensions,
		IgnoreList:           watcher.DefaultIgnoreList,
		Interactive:          rt.Interactive,
		ShouldLogWatchEvents: false,
	})
	if err != nil {
		return err
	}
	defer watch.Close()

	var wg sync.WaitGroup
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	wg.Add(1)
	go func() {
		defer wg.Done()
		_ = pipeline.Start(ctx)
	}()

	wg.Add(1)
	go func() {
		defer wg.Done()
		watch.Watch(ctx)
	}()

	for ev := range watch.GetEvents() {
		slog.Debug("received", "event", ev)
		// existing reload logging
	}

	cancel()
	wg.Wait()
	return nil
}
```

This keeps `RunTask` as a high‑level entry point, with a single pipeline lifecycle.

---

### 2. Split env evaluation + recursion in `createSteps`

`createSteps` mixes:

- Recursive `run` expansion
- Env merging and evaluation
- Logging prefix construction
- Step/command creation

You can first extract env resolution and the recursive expansion into helpers to shorten the core function.

Env helper:

```go
func (r *Resolver) buildEnv(task *ResolvedTask, cmd *Command, baseEnv map[string]string) (map[string]string, error) {
	envStore := fn.MapMerge(r.Env, baseEnv)

	if task.Env != nil {
		if err := parseEnvInto(context.TODO(), envStore, task.Env); err != nil {
			return nil, err
		}
	}
	if cmd.Env != nil {
		if err := parseEnvInto(context.TODO(), envStore, cmd.Env); err != nil {
			return nil, err
		}
	}
	return envStore, nil
}
```

Then keep recursion focused on structure:

```go
func (r *Resolver) createSteps(task *ResolvedTask, args createCommandGroupArgs) ([]executor.Step, error) {
	taskTrail := append(append([]string{}, args.TaskTrail...), task.Name)
	logPrefix := strings.Join(taskTrail, " ≫ ")

	var steps []executor.Step
	for _, cmd := range task.Commands {
		envStore, err := r.buildEnv(task, cmd, args.Env)
		if err != nil {
			return nil, err
		}

		if cmd.IsRunTarget {
			rt, err := r.GetTask(cmd.Text)
			if err != nil {
				return nil, err
			}
			substeps, err := r.createSteps(rt, createCommandGroupArgs{
				Stdout:    args.Stdout,
				Stderr:    args.Stderr,
				Env:       envStore,
				Silent:    task.Silent || rt.Silent,
				TaskTrail: taskTrail,
			})
			if err != nil {
				return nil, err
			}
			steps = append(steps, executor.Step{SubSteps: substeps, Parallel: rt.Parallel})
			continue
		}

		steps = append(steps, r.buildShellStep(task, cmd, envStore, args, logPrefix))
	}

	return steps, nil
}
```

And isolate the pure execution wiring (handler, hooks, interactive) in a small helper:

```go
func (r *Resolver) buildShellStep(
	task *ResolvedTask,
	cmd *Command,
	env map[string]string,
	args createCommandGroupArgs,
	logPrefix string,
) executor.Step {
	step := executor.Step{Parallel: task.Parallel}

	cmdHandler := func(c context.Context) *exec.Cmd {
		return CreateCommand(c, CmdArgs{
			Shell:      task.Shell,
			Env:        fn.ToEnviron(env),
			Cmd:        cmd.Text,
			WorkingDir: task.Dir,
			Stdout:     args.Stdout.WithPrefix(logPrefix),
			Stderr:     args.Stderr.WithPrefix(logPrefix),
		})
	}

	preHook := func(c context.Context) error {
		if task.Silent || task.Interactive {
			return nil
		}
		lang := "bash"
		if len(task.Shell) > 0 {
			lang = task.Shell[0]
		}
		printCommand(args.Stderr, logPrefix, lang, strings.TrimSpace(cmd.Text))
		return nil
	}

	if task.Interactive {
		step.Commands = append(step.Commands, executor.NewInteractiveShellCommand(cmdHandler).AddPreHook(preHook))
	} else {
		step.Commands = append(step.Commands, executor.NewShellCommand(cmdHandler).AddPreHook(preHook))
	}
	return step
}
```

This keeps `createSteps` mostly about traversal and step composition.

---

### 3. Move presentation helpers out of core resolver

`printCommand`, `longestLineLen`, `padString`, and theme detection embed UI into the resolver.

You can keep behavior but move this into a separate file and interface, then inject it:

```go
// ui/command_printer.go
type CommandPrinter interface {
	Print(w *writer.LogWriter, prefix, lang, cmd string)
}
```

Resolver only needs the interface:

```go
type Resolver struct {
	// ...
	cmdPrinter CommandPrinter
}
```

Use in `preHook`:

```go
preHook := func(c context.Context) error {
	if task.Silent || task.Interactive {
		return nil
	}
	r.cmdPrinter.Print(args.Stderr, logPrefix, lang, strings.TrimSpace(cmd.Text))
	return nil
}
```

This shortens `task.go` and decouples execution from the chosen visual style while keeping existing appearance via a concrete implementation in another file/package.

---

These extractions keep all features, but localize orchestration, env/recursion, and UI, making the core resolver and runner logic easier to follow and test.
</issue_to_address>

### Comment 9
<location> `pkg/runfile/resolver/env.go:84` </location>
<code_context>
						lazyEvalMap[k] = exec.CommandContext(ctx, shell[0], append(shell[1:], envEvalScript)...)
</code_context>

<issue_to_address>
**security (go.lang.security.audit.dangerous-exec-command):** Detected non-static command inside Command. Audit the input to 'exec.Command'. If unverified user data can reach this call site, this is a code injection vulnerability. A malicious actor can inject a malicious script to execute arbitrary code.

*Source: opengrep*
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread cmd/run/main.go
Comment thread pkg/runfile/resolver/env.go Outdated
Comment thread pkg/runfile/resolver/task.go
Comment thread cmd/run/completions/run.fish Outdated
Comment thread Runfile.yml
Comment thread pkg/runfile/resolver/task_test.go Outdated
Comment on lines +486 to +490
// Helper to check if a step uses the correct executor type
func isInteractiveStep(step executor.Step) bool {
// This is a basic check - in real tests you might want to
// use reflection or add a method to identify command types
return len(step.Commands) > 0
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (testing): Unused helper isInteractiveStep suggests tests might be incomplete for distinguishing interactive vs non-interactive commands

This helper isn’t used and its current implementation (len(step.Commands) > 0) doesn’t distinguish interactive vs non‑interactive commands, so it isn’t validating the intended behavior. I recommend either removing it, or wiring it into tests that explicitly assert interactive tasks use NewInteractiveShellCommand and non‑interactive ones use NewShellCommand (e.g., via a test‑only marker/interface on the command type).

"testing"
)

func TestParseDotEnvFilesInto(t *testing.T) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): ParseDotEnvFilesInto tests don’t cover invalid dotenv file contents

Current cases cover empty lists, relative paths, missing files, and key overrides, but not the error path from godotenv.Parse.

Please add a test that:

  • Creates a .env file with invalid syntax (e.g., INVALID LINE WITHOUT EQUALS),
  • Passes its absolute path to parseDotEnvFilesInto, and
  • Asserts an error is returned, ideally checking that it reflects a parse failure rather than just file I/O.

This will verify behavior when users provide malformed .env files.

Suggested implementation:

import (
	"context"
	"maps"
	"os"
	"path/filepath"
	"strings"
	"testing"
)
	}{
		{
			name:         "when files list is empty, it must pass",
		},
		{
			name: "when dotenv file has invalid syntax, it must return a parse error",
			setupFiles: map[string]string{
				// invalid dotenv syntax: missing '=' separator
				".env": "INVALID LINE WITHOUT EQUALS",
			},
			// This filename will be converted to an absolute path in the test setup;
			// we do not use relative/non-existent path flags so that we exercise
			// the parse error path instead of I/O failures.
			files:        []string{".env"},
			initialStore: map[string]string{},
			// No variables are expected to be loaded on parse failure.
			expected:       map[string]string{},
			wantErr:        true,
			useRelPath:     false,
			useNonExistent: false,
		},

To fully satisfy the "ideally checking that it reflects a parse failure" part of your comment, you should also update the test loop in TestParseDotEnvFilesInto (the part that calls parseDotEnvFilesInto) to special-case this new scenario and assert on the error message, for example:

  1. After calling err := parseDotEnvFilesInto(ctx, store, files...) and before the generic wantErr checks, add logic such as:
if tt.name == "when dotenv file has invalid syntax, it must return a parse error" {
    if err == nil {
        t.Fatalf("expected parse error, got nil")
    }
    if !strings.Contains(err.Error(), "parse") && !strings.Contains(err.Error(), "invalid line") {
        t.Fatalf("expected parse-related error, got %q", err)
    }
}
  1. Adjust variable names (err, tt, etc.) to match the existing test body. Ensure this check runs only for this specific test case so other tests remain unchanged.

}, nil
}

func (r *Resolver) RunTask(ctx context.Context, name string) error {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider refactoring RunTask, createSteps, and the printing helpers into smaller focused helpers and interfaces to simplify orchestration, recursion, and UI concerns in this resolver.

You can reduce complexity meaningfully here without changing behavior by:


1. Untangle RunTask orchestration and watcher lifecycle

RunTask currently:

  • Builds steps and pipeline
  • Handles watch creation
  • Manages multiple goroutines + WaitGroup
  • Calls pipeline.Start(ctx) more than once
  • Contains commented‑out experimental code

You can push the concurrency + watch wiring into a helper and keep a single Start call:

func (r *Resolver) RunTask(ctx context.Context, name string) error {
	rt, err := r.GetTask(name)
	if err != nil {
		return err
	}

	lw := &writer.LogWriter{Writer: os.Stderr}
	steps, err := r.createSteps(rt, createCommandGroupArgs{
		Stdout: lw,
		Stderr: lw,
	})
	if err != nil {
		return err
	}

	pipeline := executor.NewPipeline(slog.Default(), steps)

	if rt.Watch == nil || !rt.Watch.Enabled {
		return pipeline.Start(ctx)
	}

	return runWithWatch(ctx, rt, pipeline)
}

Then put all the goroutine/WaitGroup handling into a dedicated helper:

func runWithWatch(ctx context.Context, rt *ResolvedTask, pipeline *executor.Pipeline) error {
	watch, err := watcher.NewWatcher(ctx, watcher.WatcherArgs{
		WatchDirs:            rt.Watch.Dirs,
		IgnoreDirs:           rt.Watch.IgnoreDirs,
		WatchExtensions:      rt.Watch.Extensions,
		IgnoreExtensions:     rt.Watch.IgnoreExtensions,
		IgnoreList:           watcher.DefaultIgnoreList,
		Interactive:          rt.Interactive,
		ShouldLogWatchEvents: false,
	})
	if err != nil {
		return err
	}
	defer watch.Close()

	var wg sync.WaitGroup
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	wg.Add(1)
	go func() {
		defer wg.Done()
		_ = pipeline.Start(ctx)
	}()

	wg.Add(1)
	go func() {
		defer wg.Done()
		watch.Watch(ctx)
	}()

	for ev := range watch.GetEvents() {
		slog.Debug("received", "event", ev)
		// existing reload logging
	}

	cancel()
	wg.Wait()
	return nil
}

This keeps RunTask as a high‑level entry point, with a single pipeline lifecycle.


2. Split env evaluation + recursion in createSteps

createSteps mixes:

  • Recursive run expansion
  • Env merging and evaluation
  • Logging prefix construction
  • Step/command creation

You can first extract env resolution and the recursive expansion into helpers to shorten the core function.

Env helper:

func (r *Resolver) buildEnv(task *ResolvedTask, cmd *Command, baseEnv map[string]string) (map[string]string, error) {
	envStore := fn.MapMerge(r.Env, baseEnv)

	if task.Env != nil {
		if err := parseEnvInto(context.TODO(), envStore, task.Env); err != nil {
			return nil, err
		}
	}
	if cmd.Env != nil {
		if err := parseEnvInto(context.TODO(), envStore, cmd.Env); err != nil {
			return nil, err
		}
	}
	return envStore, nil
}

Then keep recursion focused on structure:

func (r *Resolver) createSteps(task *ResolvedTask, args createCommandGroupArgs) ([]executor.Step, error) {
	taskTrail := append(append([]string{}, args.TaskTrail...), task.Name)
	logPrefix := strings.Join(taskTrail, " ≫ ")

	var steps []executor.Step
	for _, cmd := range task.Commands {
		envStore, err := r.buildEnv(task, cmd, args.Env)
		if err != nil {
			return nil, err
		}

		if cmd.IsRunTarget {
			rt, err := r.GetTask(cmd.Text)
			if err != nil {
				return nil, err
			}
			substeps, err := r.createSteps(rt, createCommandGroupArgs{
				Stdout:    args.Stdout,
				Stderr:    args.Stderr,
				Env:       envStore,
				Silent:    task.Silent || rt.Silent,
				TaskTrail: taskTrail,
			})
			if err != nil {
				return nil, err
			}
			steps = append(steps, executor.Step{SubSteps: substeps, Parallel: rt.Parallel})
			continue
		}

		steps = append(steps, r.buildShellStep(task, cmd, envStore, args, logPrefix))
	}

	return steps, nil
}

And isolate the pure execution wiring (handler, hooks, interactive) in a small helper:

func (r *Resolver) buildShellStep(
	task *ResolvedTask,
	cmd *Command,
	env map[string]string,
	args createCommandGroupArgs,
	logPrefix string,
) executor.Step {
	step := executor.Step{Parallel: task.Parallel}

	cmdHandler := func(c context.Context) *exec.Cmd {
		return CreateCommand(c, CmdArgs{
			Shell:      task.Shell,
			Env:        fn.ToEnviron(env),
			Cmd:        cmd.Text,
			WorkingDir: task.Dir,
			Stdout:     args.Stdout.WithPrefix(logPrefix),
			Stderr:     args.Stderr.WithPrefix(logPrefix),
		})
	}

	preHook := func(c context.Context) error {
		if task.Silent || task.Interactive {
			return nil
		}
		lang := "bash"
		if len(task.Shell) > 0 {
			lang = task.Shell[0]
		}
		printCommand(args.Stderr, logPrefix, lang, strings.TrimSpace(cmd.Text))
		return nil
	}

	if task.Interactive {
		step.Commands = append(step.Commands, executor.NewInteractiveShellCommand(cmdHandler).AddPreHook(preHook))
	} else {
		step.Commands = append(step.Commands, executor.NewShellCommand(cmdHandler).AddPreHook(preHook))
	}
	return step
}

This keeps createSteps mostly about traversal and step composition.


3. Move presentation helpers out of core resolver

printCommand, longestLineLen, padString, and theme detection embed UI into the resolver.

You can keep behavior but move this into a separate file and interface, then inject it:

// ui/command_printer.go
type CommandPrinter interface {
	Print(w *writer.LogWriter, prefix, lang, cmd string)
}

Resolver only needs the interface:

type Resolver struct {
	// ...
	cmdPrinter CommandPrinter
}

Use in preHook:

preHook := func(c context.Context) error {
	if task.Silent || task.Interactive {
		return nil
	}
	r.cmdPrinter.Print(args.Stderr, logPrefix, lang, strings.TrimSpace(cmd.Text))
	return nil
}

This shortens task.go and decouples execution from the chosen visual style while keeping existing appearance via a concrete implementation in another file/package.


These extractions keep all features, but localize orchestration, env/recursion, and UI, making the core resolver and runner logic easier to follow and test.

Comment thread pkg/runfile/resolver/env.go Outdated
@nxtcoder17
Copy link
Copy Markdown
Owner Author

@sourcery-ai review

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 security issue, 5 other issues, and left some high level feedback:

Security issues:

  • Detected non-static command inside Command. Audit the input to 'exec.Command'. If unverified user data can reach this call site, this is a code injection vulnerability. A malicious actor can inject a malicious script to execute arbitrary code. (link)

General comments:

  • The new locateRunfile loop using getRunfilePath will return an empty path with nil error as soon as ErrRunfileNotFound is hit in the first directory; instead, treat ErrRunfileNotFound as a signal to move up a directory and only return when a real path is found or you reach the root.
  • The --list handling in main.go is now effectively disabled (the body is fully commented out), so passing --list falls through to normal task execution; consider either removing the flag or restoring some minimal behavior to avoid a confusing no-op.
  • In NewInteractiveShellCommand, the PTY-based command always binds I/O directly to os.Stdin/os.Stdout, ignoring the configured writers and losing prefixes/log routing; if possible, thread the provided Stdout/Stderr through or document that interactive tasks bypass the log writer.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `locateRunfile` loop using `getRunfilePath` will return an empty path with `nil` error as soon as `ErrRunfileNotFound` is hit in the first directory; instead, treat `ErrRunfileNotFound` as a signal to move up a directory and only return when a real path is found or you reach the root.
- The `--list` handling in `main.go` is now effectively disabled (the body is fully commented out), so passing `--list` falls through to normal task execution; consider either removing the flag or restoring some minimal behavior to avoid a confusing no-op.
- In `NewInteractiveShellCommand`, the PTY-based command always binds I/O directly to `os.Stdin`/`os.Stdout`, ignoring the configured writers and losing prefixes/log routing; if possible, thread the provided `Stdout`/`Stderr` through or document that interactive tasks bypass the log writer.

## Individual Comments

### Comment 1
<location> `pkg/runfile/resolver/env_test.go:11` </location>
<code_context>
+	"testing"
+)
+
+func TestParseDotEnvFilesInto(t *testing.T) {
+	tests := []struct {
+		name           string
</code_context>

<issue_to_address>
**suggestion (testing):** Add a test for invalid dotenv file contents to exercise error handling

Current tests cover many scenarios but not an invalid dotenv file (e.g., malformed line like `FOO=bar=baz` or arbitrary junk). Please add a small malformed fixture to assert that `parseDotEnvFilesInto` propagates the parse error from `godotenv.Parse`, so the error path for misconfigured environments is exercised.

Suggested implementation:

```golang
	"testing"
)

```

To exercise the error path for malformed dotenv contents, add a dedicated test that writes an invalid `.env` file and asserts that `parseDotEnvFilesInto` returns an error. Place this new test in `pkg/runfile/resolver/env_test.go`, near the existing `TestParseDotEnvFilesInto` (e.g., immediately after it):

```go
func TestParseDotEnvFilesInto_InvalidDotenvContent(t *testing.T) {
	// Create a temporary directory for the malformed dotenv file.
	dir := t.TempDir()

	// Malformed dotenv content: `godotenv.Parse` treats lines with multiple '='
	// as invalid and returns an error.
	const malformedContent = "FOO=bar=baz\n"

	envPath := filepath.Join(dir, ".env.malformed")
	if err := os.WriteFile(envPath, []byte(malformedContent), 0o600); err != nil {
		t.Fatalf("failed to write malformed dotenv file: %v", err)
	}

	// Use a fresh store to ensure we don't depend on any previously set env.
	store := map[string]string{}

	err := parseDotEnvFilesInto(context.Background(), []string{envPath}, store)

	if err == nil {
		t.Fatalf("expected error for malformed dotenv file, got nil")
	}
}
```

If `parseDotEnvFilesInto` currently swallows errors from `godotenv.Parse`, adjust its implementation so that it returns those errors instead. For example, if it looks roughly like:

```go
func parseDotEnvFilesInto(ctx context.Context, files []string, store map[string]string) error {
	for _, f := range files {
		// ...
		envMap, err := godotenv.Parse(fh)
		if err != nil {
			// previously: continue or log
		}
		// ...
	}
	return nil
}
```

change the error handling to:

```go
		envMap, err := godotenv.Parse(fh)
		if err != nil {
			return fmt.Errorf("parse %s: %w", f, err)
		}
```

so that the new test can assert that malformed dotenv files cause `parseDotEnvFilesInto` to fail.
</issue_to_address>

### Comment 2
<location> `pkg/runfile/resolver/task.go:183` </location>
<code_context>
+	return nil
+}
+
+func parseCommands(commands []any) ([]*Command, error) {
+	result := make([]*Command, 0, len(commands))
+
</code_context>

<issue_to_address>
**issue (complexity):** Consider applying small refactors (direct map inspection, env-building helper, trail helper, and a printer abstraction) to simplify `parseCommands` and `createSteps` without changing behavior.

A few targeted refactors could reduce complexity without changing behavior:

### 1. Avoid JSON round‑trip in `parseCommands`

The marshal/unmarshal of `map[string]any` adds overhead and obscures intent. You can directly inspect the map keys and types:

```go
func parseCommands(commands []any) ([]*Command, error) {
	result := make([]*Command, 0, len(commands))

	for _, command := range commands {
		switch c := command.(type) {
		case string:
			if strings.TrimSpace(c) == "" {
				continue
			}
			result = append(result, &Command{Text: c})

		case map[string]any:
			cmd := &Command{}
			if env, ok := c["env"]; ok {
				if m, ok := env.(map[string]any); ok {
					cmd.Env = m
				} else {
					return nil, errors.New("env must be an object").KV("env", env)
				}
			}

			if run, ok := c["run"]; ok {
				runStr, ok := run.(string)
				if !ok || strings.TrimSpace(runStr) == "" {
					return nil, errors.New("empty or invalid run target").KV("run", run)
				}
				cmd.Text = runStr
				cmd.IsRunTarget = true
			} else if commandVal, ok := c["command"]; ok {
				cmdStr, ok := commandVal.(string)
				if !ok || strings.TrimSpace(cmdStr) == "" {
					return nil, errors.New("empty or invalid command").KV("command", commandVal)
				}
				cmd.Text = cmdStr
			} else if cmdVal, ok := c["cmd"]; ok {
				cmdStr, ok := cmdVal.(string)
				if !ok || strings.TrimSpace(cmdStr) == "" {
					return nil, errors.New("empty or invalid cmd").KV("cmd", cmdVal)
				}
				cmd.Text = cmdStr
			} else {
				return nil, errors.New("either 'run' or 'command'/'cmd' must be specified")
			}

			result = append(result, cmd)

		default:
			return nil, errors.New("invalid command type, must be either a string or an object")
		}
	}

	return result, nil
}
```

This preserves behavior but removes JSON and extra error paths.

---

### 2. Extract env building from `createSteps`

`createSteps` currently merges env in‑line for every command, which contributes to its size and nesting. A small helper keeps the orchestration logic clearer:

```go
func (r *Resolver) buildEnv(base map[string]string, taskEnv, cmdEnv map[string]any) (map[string]string, error) {
	envStore := fn.MapMerge(r.Env, base)

	if taskEnv != nil {
		if err := parseEnvInto(context.TODO(), envStore, taskEnv); err != nil {
			return nil, err
		}
	}

	if cmdEnv != nil {
		if err := parseEnvInto(context.TODO(), envStore, cmdEnv); err != nil {
			return nil, err
		}
	}

	return envStore, nil
}
```

Use it in `createSteps`:

```go
for _, cmd := range task.Commands {
	envStore, err := r.buildEnv(args.Env, task.Env, cmd.Env)
	if err != nil {
		return nil, err
	}

	// existing IsRunTarget / shell command logic...
}
```

---

### 3. Extract task trail extension

The manual slice copy is noisy. Encapsulate into a tiny helper:

```go
func extendTrail(trail []string, name string) []string {
	newTrail := make([]string, len(trail)+1)
	copy(newTrail, trail)
	newTrail[len(trail)] = name
	return newTrail
}
```

Then in `createSteps`:

```go
taskTrail := extendTrail(args.TaskTrail, task.Name)
```

This keeps the core flow in `createSteps` more focused on the execution graph.

---

### 4. Separate printing from step construction

You already use `preHook` for printing. To decouple styling from execution, you can have the hook accept a simple description struct and move chroma/lipgloss into a dedicated printer:

```go
type CommandDesc struct {
	Prefix string
	Lang   string
	Text   string
}

type CommandPrinter interface {
	Print(*writer.LogWriter, CommandDesc)
}
```

Printer implementation (styling kept intact, just moved):

```go
type StyledPrinter struct{}

func (StyledPrinter) Print(w *writer.LogWriter, d CommandDesc) {
	printCommand(w, d.Prefix, d.Lang, d.Text)
}
```

Then wire in `createSteps`:

```go
func (r *Resolver) createSteps(task *ResolvedTask, args createCommandGroupArgs) ([]executor.Step, error) {
	// ...
	printer := StyledPrinter{} // or injected

	preHook := func(c context.Context) error {
		if task.Silent || task.Interactive {
			return nil
		}
		str := strings.TrimSpace(cmd.Text)
		lang := "bash"
		if len(task.Shell) > 0 {
			lang = task.Shell[0]
		}
		printer.Print(args.Stderr, CommandDesc{
			Prefix: logPrefix,
			Lang:   lang,
			Text:   str,
		})
		return nil
	}
	// ...
}
```

The behavior stays the same, but `createSteps` no longer needs to know about chroma or lipgloss details, making the orchestration logic easier to read and maintain.
</issue_to_address>

### Comment 3
<location> `pkg/runfile/resolver/env.go:44` </location>
<code_context>
+	return nil
+}
+
+func parseEnvInto(ctx context.Context, envStore map[string]string, envMap map[string]any) error {
+	lookupEnv := func(key string) (string, bool) {
+		if v, ok := os.LookupEnv(key); ok {
</code_context>

<issue_to_address>
**issue (complexity):** Consider refactoring `parseEnvInto` by introducing an explicit env expression type and helper functions to separate parsing from evaluation and decouple from `exec.Cmd`.

You can simplify this without losing any functionality by (1) separating parsing from evaluation, (2) avoiding `map[string]any` “protocol” inside the loop, and (3) decoupling from `*exec.Cmd`.

### 1. Replace `lazyEvalMap` with a small expression type

Instead of `map[string]*exec.Cmd`, collect expressions in a dedicated slice. This keeps the env-building loop simple and removes the tight coupling to `exec.Cmd`:

```go
type envExpr struct {
	Key    string
	Shell  []string
	Script string
}

func parseEnvInto(ctx context.Context, envStore map[string]string, envMap map[string]any) error {
	lookupEnv := func(key string) (string, bool) {
		if v, ok := os.LookupEnv(key); ok {
			return v, true
		}
		if v, ok := envStore[key]; ok {
			return v, true
		}
		return "", false
	}

	var exprs []envExpr

	for k, v := range envMap {
		if _, ok := lookupEnv(k); ok {
			continue
		}

		switch value := v.(type) {
		case string:
			envStore[k] = value

		case map[string]any:
			expr, isExpr, err := parseEnvSpec(k, value)
			if err != nil {
				return err
			}
			if isExpr {
				exprs = append(exprs, expr)
			}

		default:
			envStore[k] = fmt.Sprint(v)
		}
	}

	if err := evalEnvExpressions(ctx, envStore, exprs); err != nil {
		return err
	}

	return nil
}
```

### 2. Make the map semantics explicit with a helper

Instead of scanning `map[string]any` inline with magic keys, pull that into a dedicated parser. This keeps all the error handling and key logic in one place:

```go
var shellAliasMap = map[string][]string{
	"bash": {"bash", "-c"},
	"sh":   {"sh", "-c"},
	// ...
}

func parseEnvSpec(key string, m map[string]any) (envExpr, bool, error) {
	if requiredVal, ok := m["required"]; ok {
		isRequired, ok := requiredVal.(bool)
		if !ok {
			return envExpr{}, false,
				errors.New("ENV-EXPRESSION: value field `required` must be a boolean").
					KV("env.key", key, "env.value", m)
		}
		if isRequired {
			return envExpr{}, false,
				errors.New(fmt.Sprintf("ENV-EXPRESSION: env var '%s' is required, it must be provided", key)).
					KV("env.key", key, "env.value", m)
		}
	}

	for optKey, optVal := range m {
		if shell, ok := shellAliasMap[optKey]; ok {
			script, ok := optVal.(string)
			if !ok {
				return envExpr{}, false,
					errors.New(fmt.Sprintf("ENV-EXPRESSION: value field `%s`, must have a string value", optKey)).
						KV("env.key", key, "env.value", m)
			}
			return envExpr{
				Key:    key,
				Shell:  shell,
				Script: script,
			}, true, nil
		}
	}

	// no expression fields: treat as no-op for now
	return envExpr{}, false, nil
}
```

This preserves the existing behavior (`required`, shell aliases, error messages) but removes the need for type switches and key scanning inside `parseEnvInto`.

### 3. Isolate evaluation logic and reuse `envStore` only via `ToEnviron`

Move the `exec.Cmd` wiring and command execution into a dedicated helper that accepts your explicit env expression type:

```go
func evalEnvExpressions(ctx context.Context, envStore map[string]string, exprs []envExpr) error {
	if len(exprs) == 0 {
		return nil
	}

	cmdEnv := fn.ToEnviron(envStore)

	for _, e := range exprs {
		cmd := exec.CommandContext(ctx, e.Shell[0], append(e.Shell[1:], e.Script)...)
		cmd.Env = cmdEnv

		stdout := new(bytes.Buffer)
		stderr := new(bytes.Buffer)
		cmd.Stdout = stdout
		cmd.Stderr = stderr

		if err := cmd.Run(); err != nil {
			return errors.New("ENV-EXPRESSION: evaluation failed").
				Wrap(err).
				KV("env.key", e.Key, "eval.cmd", cmd.String(), "eval.stderr", stderr.String())
		}

		envStore[e.Key] = strings.TrimSpace(stdout.String())
	}

	return nil
}
```

This keeps all command-related complexity in one place and makes `parseEnvInto` mostly about mapping input declarations to either direct values or `envExpr` entries.

These changes keep all current features (dotenv, required flags, shell expressions, lazy evaluation using the fully-populated env), while making the main function smaller and more declarative.
</issue_to_address>

### Comment 4
<location> `pkg/executor/command.go:12` </location>
<code_context>
+	Run(ctx context.Context) error
+}
+
+type command struct {
+	run       func(ctx context.Context) error
+	preHooks  []func(ctx context.Context) error
</code_context>

<issue_to_address>
**issue (complexity):** Consider replacing the concrete command struct and hook slices with a composable CommandFunc function type and small wrapper helpers to keep hooks while simplifying the abstraction.

The current `command` struct + interface + hook slices add indirection that isn’t currently buying you anything. You can keep pre/post‑hook behavior and extensibility while simplifying the model around a single function type.

A minimal alternative that preserves all functionality:

```go
type Command interface {
	Run(ctx context.Context) error
}

type CommandFunc func(context.Context) error

func (f CommandFunc) Run(ctx context.Context) error {
	return f(ctx)
}

func WithPreHook(cmd CommandFunc, hook CommandFunc) CommandFunc {
	return func(ctx context.Context) error {
		if err := hook(ctx); err != nil {
			return err
		}
		return cmd(ctx)
	}
}

func WithPostHook(cmd CommandFunc, hook CommandFunc) CommandFunc {
	return func(ctx context.Context) error {
		if err := cmd(ctx); err != nil {
			return err
		}
		return hook(ctx)
	}
}
```

Usage stays simple and composable, without the extra struct state and slice management:

```go
base := CommandFunc(func(ctx context.Context) error {
	// core logic
	return nil
})

cmd := WithPreHook(
	WithPostHook(base, postHook),
	preHook,
)

// still satisfies Command
err := cmd.Run(ctx)
```

This keeps:

- A `Command` abstraction (via the interface and `CommandFunc` adapter).
- Support for pre and post hooks (via composition).
- Extensibility (you can add more helpers like `WithTimeout`, `WithMetrics`, etc.).

But it removes the need to track `preHooks`/`postHooks` slices and a concrete `command` struct, reducing complexity when reading and debugging the pipeline.
</issue_to_address>

### Comment 5
<location> `pkg/executor/pipeline.go:59` </location>
<code_context>
+	return nil
+}
+
+func (p *Pipeline) execStep(ctx context.Context, step *Step) error {
+	if err := p.execSubSteps(ctx, step); err != nil {
+		return err
</code_context>

<issue_to_address>
**issue (complexity):** Consider making `execStep` explicitly enforce or branch on the mutually exclusive use of `SubSteps` versus `Commands` so the Step’s role and control flow are easier to understand.

You can reduce the cognitive load without changing the public shape of `Step` by making the “either SubSteps or Commands” rule explicit in `execStep`, instead of always running both paths.

Right now, `execStep` silently runs *both* `SubSteps` and `Commands` if someone populates both, which contradicts the comment and makes the control flow harder to reason about.

You can encode the intended invariant and simplify the execution logic like this:

```go
func (p *Pipeline) execStep(ctx context.Context, step *Step) error {
	hasSub := len(step.SubSteps) > 0
	hasCmd := len(step.Commands) > 0

	switch {
	case hasSub && hasCmd:
		// Enforce the invariant documented on Step.
		// You can also choose to log and run one branch if you prefer.
		return fmt.Errorf("invalid Step: cannot have both SubSteps and Commands")

	case hasSub:
		return p.execSubSteps(ctx, step)

	case hasCmd:
		return p.execCommands(ctx, step)

	default:
		// Empty step is a no-op.
		return nil
	}
}
```

If you don’t want to fail hard, you can still make control flow explicit while preserving the current “run both” behavior, which is easier to read than the current unconditional calls:

```go
func (p *Pipeline) execStep(ctx context.Context, step *Step) error {
	if len(step.SubSteps) > 0 {
		if err := p.execSubSteps(ctx, step); err != nil {
			return err
		}
	}
	if len(step.Commands) > 0 {
		if err := p.execCommands(ctx, step); err != nil {
			return err
		}
	}
	return nil
}
```

Either version makes the Step roles much clearer and directly reflects the intended model, reducing the “single struct doing two roles” confusion without changing the overall pipeline abstraction.
</issue_to_address>

### Comment 6
<location> `pkg/runfile/resolver/env.go:87` </location>
<code_context>
						lazyEvalMap[k] = exec.CommandContext(ctx, shell[0], append(shell[1:], envEvalScript)...)
</code_context>

<issue_to_address>
**security (go.lang.security.audit.dangerous-exec-command):** Detected non-static command inside Command. Audit the input to 'exec.Command'. If unverified user data can reach this call site, this is a code injection vulnerability. A malicious actor can inject a malicious script to execute arbitrary code.

*Source: opengrep*
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

"testing"
)

func TestParseDotEnvFilesInto(t *testing.T) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a test for invalid dotenv file contents to exercise error handling

Current tests cover many scenarios but not an invalid dotenv file (e.g., malformed line like FOO=bar=baz or arbitrary junk). Please add a small malformed fixture to assert that parseDotEnvFilesInto propagates the parse error from godotenv.Parse, so the error path for misconfigured environments is exercised.

Suggested implementation:

	"testing"
)

To exercise the error path for malformed dotenv contents, add a dedicated test that writes an invalid .env file and asserts that parseDotEnvFilesInto returns an error. Place this new test in pkg/runfile/resolver/env_test.go, near the existing TestParseDotEnvFilesInto (e.g., immediately after it):

func TestParseDotEnvFilesInto_InvalidDotenvContent(t *testing.T) {
	// Create a temporary directory for the malformed dotenv file.
	dir := t.TempDir()

	// Malformed dotenv content: `godotenv.Parse` treats lines with multiple '='
	// as invalid and returns an error.
	const malformedContent = "FOO=bar=baz\n"

	envPath := filepath.Join(dir, ".env.malformed")
	if err := os.WriteFile(envPath, []byte(malformedContent), 0o600); err != nil {
		t.Fatalf("failed to write malformed dotenv file: %v", err)
	}

	// Use a fresh store to ensure we don't depend on any previously set env.
	store := map[string]string{}

	err := parseDotEnvFilesInto(context.Background(), []string{envPath}, store)

	if err == nil {
		t.Fatalf("expected error for malformed dotenv file, got nil")
	}
}

If parseDotEnvFilesInto currently swallows errors from godotenv.Parse, adjust its implementation so that it returns those errors instead. For example, if it looks roughly like:

func parseDotEnvFilesInto(ctx context.Context, files []string, store map[string]string) error {
	for _, f := range files {
		// ...
		envMap, err := godotenv.Parse(fh)
		if err != nil {
			// previously: continue or log
		}
		// ...
	}
	return nil
}

change the error handling to:

		envMap, err := godotenv.Parse(fh)
		if err != nil {
			return fmt.Errorf("parse %s: %w", f, err)
		}

so that the new test can assert that malformed dotenv files cause parseDotEnvFilesInto to fail.

return nil
}

func parseCommands(commands []any) ([]*Command, error) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider applying small refactors (direct map inspection, env-building helper, trail helper, and a printer abstraction) to simplify parseCommands and createSteps without changing behavior.

A few targeted refactors could reduce complexity without changing behavior:

1. Avoid JSON round‑trip in parseCommands

The marshal/unmarshal of map[string]any adds overhead and obscures intent. You can directly inspect the map keys and types:

func parseCommands(commands []any) ([]*Command, error) {
	result := make([]*Command, 0, len(commands))

	for _, command := range commands {
		switch c := command.(type) {
		case string:
			if strings.TrimSpace(c) == "" {
				continue
			}
			result = append(result, &Command{Text: c})

		case map[string]any:
			cmd := &Command{}
			if env, ok := c["env"]; ok {
				if m, ok := env.(map[string]any); ok {
					cmd.Env = m
				} else {
					return nil, errors.New("env must be an object").KV("env", env)
				}
			}

			if run, ok := c["run"]; ok {
				runStr, ok := run.(string)
				if !ok || strings.TrimSpace(runStr) == "" {
					return nil, errors.New("empty or invalid run target").KV("run", run)
				}
				cmd.Text = runStr
				cmd.IsRunTarget = true
			} else if commandVal, ok := c["command"]; ok {
				cmdStr, ok := commandVal.(string)
				if !ok || strings.TrimSpace(cmdStr) == "" {
					return nil, errors.New("empty or invalid command").KV("command", commandVal)
				}
				cmd.Text = cmdStr
			} else if cmdVal, ok := c["cmd"]; ok {
				cmdStr, ok := cmdVal.(string)
				if !ok || strings.TrimSpace(cmdStr) == "" {
					return nil, errors.New("empty or invalid cmd").KV("cmd", cmdVal)
				}
				cmd.Text = cmdStr
			} else {
				return nil, errors.New("either 'run' or 'command'/'cmd' must be specified")
			}

			result = append(result, cmd)

		default:
			return nil, errors.New("invalid command type, must be either a string or an object")
		}
	}

	return result, nil
}

This preserves behavior but removes JSON and extra error paths.


2. Extract env building from createSteps

createSteps currently merges env in‑line for every command, which contributes to its size and nesting. A small helper keeps the orchestration logic clearer:

func (r *Resolver) buildEnv(base map[string]string, taskEnv, cmdEnv map[string]any) (map[string]string, error) {
	envStore := fn.MapMerge(r.Env, base)

	if taskEnv != nil {
		if err := parseEnvInto(context.TODO(), envStore, taskEnv); err != nil {
			return nil, err
		}
	}

	if cmdEnv != nil {
		if err := parseEnvInto(context.TODO(), envStore, cmdEnv); err != nil {
			return nil, err
		}
	}

	return envStore, nil
}

Use it in createSteps:

for _, cmd := range task.Commands {
	envStore, err := r.buildEnv(args.Env, task.Env, cmd.Env)
	if err != nil {
		return nil, err
	}

	// existing IsRunTarget / shell command logic...
}

3. Extract task trail extension

The manual slice copy is noisy. Encapsulate into a tiny helper:

func extendTrail(trail []string, name string) []string {
	newTrail := make([]string, len(trail)+1)
	copy(newTrail, trail)
	newTrail[len(trail)] = name
	return newTrail
}

Then in createSteps:

taskTrail := extendTrail(args.TaskTrail, task.Name)

This keeps the core flow in createSteps more focused on the execution graph.


4. Separate printing from step construction

You already use preHook for printing. To decouple styling from execution, you can have the hook accept a simple description struct and move chroma/lipgloss into a dedicated printer:

type CommandDesc struct {
	Prefix string
	Lang   string
	Text   string
}

type CommandPrinter interface {
	Print(*writer.LogWriter, CommandDesc)
}

Printer implementation (styling kept intact, just moved):

type StyledPrinter struct{}

func (StyledPrinter) Print(w *writer.LogWriter, d CommandDesc) {
	printCommand(w, d.Prefix, d.Lang, d.Text)
}

Then wire in createSteps:

func (r *Resolver) createSteps(task *ResolvedTask, args createCommandGroupArgs) ([]executor.Step, error) {
	// ...
	printer := StyledPrinter{} // or injected

	preHook := func(c context.Context) error {
		if task.Silent || task.Interactive {
			return nil
		}
		str := strings.TrimSpace(cmd.Text)
		lang := "bash"
		if len(task.Shell) > 0 {
			lang = task.Shell[0]
		}
		printer.Print(args.Stderr, CommandDesc{
			Prefix: logPrefix,
			Lang:   lang,
			Text:   str,
		})
		return nil
	}
	// ...
}

The behavior stays the same, but createSteps no longer needs to know about chroma or lipgloss details, making the orchestration logic easier to read and maintain.

return nil
}

func parseEnvInto(ctx context.Context, envStore map[string]string, envMap map[string]any) error {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider refactoring parseEnvInto by introducing an explicit env expression type and helper functions to separate parsing from evaluation and decouple from exec.Cmd.

You can simplify this without losing any functionality by (1) separating parsing from evaluation, (2) avoiding map[string]any “protocol” inside the loop, and (3) decoupling from *exec.Cmd.

1. Replace lazyEvalMap with a small expression type

Instead of map[string]*exec.Cmd, collect expressions in a dedicated slice. This keeps the env-building loop simple and removes the tight coupling to exec.Cmd:

type envExpr struct {
	Key    string
	Shell  []string
	Script string
}

func parseEnvInto(ctx context.Context, envStore map[string]string, envMap map[string]any) error {
	lookupEnv := func(key string) (string, bool) {
		if v, ok := os.LookupEnv(key); ok {
			return v, true
		}
		if v, ok := envStore[key]; ok {
			return v, true
		}
		return "", false
	}

	var exprs []envExpr

	for k, v := range envMap {
		if _, ok := lookupEnv(k); ok {
			continue
		}

		switch value := v.(type) {
		case string:
			envStore[k] = value

		case map[string]any:
			expr, isExpr, err := parseEnvSpec(k, value)
			if err != nil {
				return err
			}
			if isExpr {
				exprs = append(exprs, expr)
			}

		default:
			envStore[k] = fmt.Sprint(v)
		}
	}

	if err := evalEnvExpressions(ctx, envStore, exprs); err != nil {
		return err
	}

	return nil
}

2. Make the map semantics explicit with a helper

Instead of scanning map[string]any inline with magic keys, pull that into a dedicated parser. This keeps all the error handling and key logic in one place:

var shellAliasMap = map[string][]string{
	"bash": {"bash", "-c"},
	"sh":   {"sh", "-c"},
	// ...
}

func parseEnvSpec(key string, m map[string]any) (envExpr, bool, error) {
	if requiredVal, ok := m["required"]; ok {
		isRequired, ok := requiredVal.(bool)
		if !ok {
			return envExpr{}, false,
				errors.New("ENV-EXPRESSION: value field `required` must be a boolean").
					KV("env.key", key, "env.value", m)
		}
		if isRequired {
			return envExpr{}, false,
				errors.New(fmt.Sprintf("ENV-EXPRESSION: env var '%s' is required, it must be provided", key)).
					KV("env.key", key, "env.value", m)
		}
	}

	for optKey, optVal := range m {
		if shell, ok := shellAliasMap[optKey]; ok {
			script, ok := optVal.(string)
			if !ok {
				return envExpr{}, false,
					errors.New(fmt.Sprintf("ENV-EXPRESSION: value field `%s`, must have a string value", optKey)).
						KV("env.key", key, "env.value", m)
			}
			return envExpr{
				Key:    key,
				Shell:  shell,
				Script: script,
			}, true, nil
		}
	}

	// no expression fields: treat as no-op for now
	return envExpr{}, false, nil
}

This preserves the existing behavior (required, shell aliases, error messages) but removes the need for type switches and key scanning inside parseEnvInto.

3. Isolate evaluation logic and reuse envStore only via ToEnviron

Move the exec.Cmd wiring and command execution into a dedicated helper that accepts your explicit env expression type:

func evalEnvExpressions(ctx context.Context, envStore map[string]string, exprs []envExpr) error {
	if len(exprs) == 0 {
		return nil
	}

	cmdEnv := fn.ToEnviron(envStore)

	for _, e := range exprs {
		cmd := exec.CommandContext(ctx, e.Shell[0], append(e.Shell[1:], e.Script)...)
		cmd.Env = cmdEnv

		stdout := new(bytes.Buffer)
		stderr := new(bytes.Buffer)
		cmd.Stdout = stdout
		cmd.Stderr = stderr

		if err := cmd.Run(); err != nil {
			return errors.New("ENV-EXPRESSION: evaluation failed").
				Wrap(err).
				KV("env.key", e.Key, "eval.cmd", cmd.String(), "eval.stderr", stderr.String())
		}

		envStore[e.Key] = strings.TrimSpace(stdout.String())
	}

	return nil
}

This keeps all command-related complexity in one place and makes parseEnvInto mostly about mapping input declarations to either direct values or envExpr entries.

These changes keep all current features (dotenv, required flags, shell expressions, lazy evaluation using the fully-populated env), while making the main function smaller and more declarative.

Comment thread pkg/executor/command.go
Run(ctx context.Context) error
}

type command struct {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider replacing the concrete command struct and hook slices with a composable CommandFunc function type and small wrapper helpers to keep hooks while simplifying the abstraction.

The current command struct + interface + hook slices add indirection that isn’t currently buying you anything. You can keep pre/post‑hook behavior and extensibility while simplifying the model around a single function type.

A minimal alternative that preserves all functionality:

type Command interface {
	Run(ctx context.Context) error
}

type CommandFunc func(context.Context) error

func (f CommandFunc) Run(ctx context.Context) error {
	return f(ctx)
}

func WithPreHook(cmd CommandFunc, hook CommandFunc) CommandFunc {
	return func(ctx context.Context) error {
		if err := hook(ctx); err != nil {
			return err
		}
		return cmd(ctx)
	}
}

func WithPostHook(cmd CommandFunc, hook CommandFunc) CommandFunc {
	return func(ctx context.Context) error {
		if err := cmd(ctx); err != nil {
			return err
		}
		return hook(ctx)
	}
}

Usage stays simple and composable, without the extra struct state and slice management:

base := CommandFunc(func(ctx context.Context) error {
	// core logic
	return nil
})

cmd := WithPreHook(
	WithPostHook(base, postHook),
	preHook,
)

// still satisfies Command
err := cmd.Run(ctx)

This keeps:

  • A Command abstraction (via the interface and CommandFunc adapter).
  • Support for pre and post hooks (via composition).
  • Extensibility (you can add more helpers like WithTimeout, WithMetrics, etc.).

But it removes the need to track preHooks/postHooks slices and a concrete command struct, reducing complexity when reading and debugging the pipeline.

Comment thread pkg/executor/pipeline.go
return nil
}

func (p *Pipeline) execStep(ctx context.Context, step *Step) error {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider making execStep explicitly enforce or branch on the mutually exclusive use of SubSteps versus Commands so the Step’s role and control flow are easier to understand.

You can reduce the cognitive load without changing the public shape of Step by making the “either SubSteps or Commands” rule explicit in execStep, instead of always running both paths.

Right now, execStep silently runs both SubSteps and Commands if someone populates both, which contradicts the comment and makes the control flow harder to reason about.

You can encode the intended invariant and simplify the execution logic like this:

func (p *Pipeline) execStep(ctx context.Context, step *Step) error {
	hasSub := len(step.SubSteps) > 0
	hasCmd := len(step.Commands) > 0

	switch {
	case hasSub && hasCmd:
		// Enforce the invariant documented on Step.
		// You can also choose to log and run one branch if you prefer.
		return fmt.Errorf("invalid Step: cannot have both SubSteps and Commands")

	case hasSub:
		return p.execSubSteps(ctx, step)

	case hasCmd:
		return p.execCommands(ctx, step)

	default:
		// Empty step is a no-op.
		return nil
	}
}

If you don’t want to fail hard, you can still make control flow explicit while preserving the current “run both” behavior, which is easier to read than the current unconditional calls:

func (p *Pipeline) execStep(ctx context.Context, step *Step) error {
	if len(step.SubSteps) > 0 {
		if err := p.execSubSteps(ctx, step); err != nil {
			return err
		}
	}
	if len(step.Commands) > 0 {
		if err := p.execCommands(ctx, step); err != nil {
			return err
		}
	}
	return nil
}

Either version makes the Step roles much clearer and directly reflects the intended model, reducing the “single struct doing two roles” confusion without changing the overall pipeline abstraction.

Comment thread pkg/runfile/resolver/env.go Outdated
return errors.New(fmt.Sprintf("ENV-EXPRESSION: value field `%s`, must have a string value", optKey)).KV("env.key", k, "env.value", value)
}

lazyEvalMap[k] = exec.CommandContext(ctx, shell[0], append(shell[1:], envEvalScript)...)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security (go.lang.security.audit.dangerous-exec-command): Detected non-static command inside Command. Audit the input to 'exec.Command'. If unverified user data can reach this call site, this is a code injection vulnerability. A malicious actor can inject a malicious script to execute arbitrary code.

Source: opengrep

@nxtcoder17 nxtcoder17 force-pushed the refactor branch 2 times, most recently from 315d7f2 to 884981a Compare February 9, 2026 17:40
@nxtcoder17
Copy link
Copy Markdown
Owner Author

@sourcery-ai review

Copy link
Copy Markdown

@SourceryAI SourceryAI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 security issue, 2 other issues, and left some high level feedback:

Security issues:

  • Detected non-static command inside Command. Audit the input to 'exec.Command'. If unverified user data can reach this call site, this is a code injection vulnerability. A malicious actor can inject a malicious script to execute arbitrary code. (link)

General comments:

  • In parseEnvInto, the required flag currently triggers an error unconditionally and doesn’t check whether the variable is already present via lookupEnv; this should only error when the key is actually missing so required variables coming from OS/envStore pass.
  • In resolver.Load, when normalizing task.DotEnv for included Runfiles you use runfileDir instead of the included file’s directory (includedDir), which will resolve dotenv paths relative to the importing Runfile rather than the included one; consider switching to includedDir there.
  • The resolver.Error type in resolver/errors.go wraps errors.Error but isn’t used anywhere in the new resolver code; either remove it or start using it consistently for resolver-specific errors to avoid dead code.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `parseEnvInto`, the `required` flag currently triggers an error unconditionally and doesn’t check whether the variable is already present via `lookupEnv`; this should only error when the key is actually missing so required variables coming from OS/envStore pass.
- In `resolver.Load`, when normalizing `task.DotEnv` for included Runfiles you use `runfileDir` instead of the included file’s directory (`includedDir`), which will resolve dotenv paths relative to the importing Runfile rather than the included one; consider switching to `includedDir` there.
- The `resolver.Error` type in `resolver/errors.go` wraps `errors.Error` but isn’t used anywhere in the new resolver code; either remove it or start using it consistently for resolver-specific errors to avoid dead code.

## Individual Comments

### Comment 1
<location> `cmd/run/main.go:244-250` </location>
<code_context>
+			continue
+		}
+
+		if stat.IsDir() {
+			return "", fmt.Errorf("Runfile.yml is a directory")
+		}
+
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Error message in `getRunfilePath` hardcodes `Runfile.yml` even when another candidate filename is used.

For directory matches, the error always says `Runfile.yml is a directory`, even when the matched file is `Runfile` or `Runfile.yaml`. Use the actual matched filename (e.g. `fmt.Errorf("%s is a directory", f)`) or the full path instead.

```suggestion
		stat, err := os.Stat(filepath.Join(dir, f))
		if err != nil {
			if !os.IsNotExist(err) {
				return "", err
			}
			continue
		}

		if stat.IsDir() {
			return "", fmt.Errorf("%s is a directory", filepath.Join(dir, f))
		}
```
</issue_to_address>

### Comment 2
<location> `pkg/runfile/resolver/task.go:1` </location>
<code_context>
+package resolver
+
+import (
</code_context>

<issue_to_address>
**issue (complexity):** Consider decomposing this large task.go file by extracting UI/printing helpers, generic command creation, and watcher orchestration into dedicated helpers/files to keep task resolution logic focused and easier to follow.

You can keep the current behavior intact while shaving off a lot of cognitive load by extracting a few clearly separable concerns into small helpers / files.

### 1. Extract UI / printing helpers out of this file

`isDarkTheme`, `printCommand`, `padString`, `longestLineLen` are UI-only and couple this file to termenv, lipgloss, chroma, term. You can move them into a `ui.go` (or `display.go`) and only expose a narrow interface:

```go
// ui.go
package resolver

import (
    "bytes"
    "fmt"
    "os"
    "strings"
    "sync"

    "github.com/alecthomas/chroma/v2/quick"
    "github.com/charmbracelet/lipgloss"
    "github.com/muesli/termenv"
    "github.com/nxtcoder17/runfile/pkg/writer"
    "golang.org/x/term"
)

var (
    darkThemeOnce   sync.Once
    darkThemeResult bool
)

func isDarkTheme() bool {
    darkThemeOnce.Do(func() {
        darkThemeResult = termenv.NewOutput(os.Stdout).HasDarkBackground()
    })
    return darkThemeResult
}

func PrintCommand(w *writer.LogWriter, prefix, lang, cmd string) {
    borderColor := "#4388cc"
    if !isDarkTheme() {
        borderColor = "#3d5485"
    }

    myBorder := lipgloss.Border{
        Top:         "-+",
        Bottom:      "-+",
        Left:        "|",
        Right:       "|",
        TopLeft:     "+",
        TopRight:    "+",
        BottomLeft:  "+",
        BottomRight: "+",
    }

    s := lipgloss.NewStyle().
        Border(myBorder).
        BorderForeground(lipgloss.Color(borderColor)).
        PaddingLeft(1).
        PaddingRight(1)
    defer s.UnsetBorderStyle()
    defer s.UnsetPadding()

    width := 0
    if term.IsTerminal(0) {
        width, _, _ = term.GetSize(0)
    }

    hlCode := new(bytes.Buffer)
    colorscheme := "catppuccin-macchiato"
    if !isDarkTheme() {
        colorscheme = "xcode"
    }

    cmdStr := strings.TrimSpace(cmd)
    longestLen := longestLineLen(cmdStr) + len(prefix) + 2
    if err := quick.Highlight(hlCode, cmdStr, lang, "terminal16m", colorscheme); err != nil {
        fmt.Fprintf(w, "%s %s\n", writer.GetStyledPrefix(prefix), cmdStr)
        return
    }

    if width > 0 && longestLen >= width-2 {
        s = s.Width(width - 2)
    }

    fmt.Fprintf(w, "\r\033[K%s%s\n", padString(s.Render(hlCode.String()), prefix), s.UnsetBorderStyle())
}

func longestLineLen(str string) int {
    sp := strings.Split(str, "\n")
    l := len(sp[0])
    for i := 1; i < len(sp); i++ {
        if len(sp[i]) > l {
            l = len(sp[i])
        }
    }
    return l
}

func padString(str string, withPrefix string) string {
    sp := strings.Split(str, "\n")
    for i := range sp {
        if i == 0 {
            sp[i] = fmt.Sprintf("%s %s", writer.GetStyledPrefix(withPrefix), sp[i])
            continue
        }
        sp[i] = fmt.Sprintf("%s %s", strings.Repeat(" ", len(withPrefix)+2), sp[i])
    }
    return strings.Join(sp, "\n")
}
```

Then in `task.go` you just call:

```go
preHook := func(c context.Context) error {
    if task.Silent || task.Interactive {
        return nil
    }
    str := strings.TrimSpace(cmd.Text)

    lang := "bash"
    if len(task.Shell) > 0 {
        lang = task.Shell[0]
    }
    PrintCommand(args.Stderr, logPrefix, lang, str)
    return nil
}
```

This keeps `createSteps` focused on execution structure instead of styling details.

### 2. Move `CreateCommand` to a `command.go` closer to execution concerns

`CreateCommand` is generic and not task-specific. Pull it into its own file so `task.go` doesn’t mix resolution + UI + low-level command wiring:

```go
// command.go
package resolver

import (
    "context"
    "io"
    "os"
    "os/exec"
)

type CmdArgs struct {
    Shell      []string
    Env        []string // [key=value,...]
    WorkingDir string

    Cmd string

    IsInteractive bool
    Stdout        io.Writer
    Stderr        io.Writer
}

func CreateCommand(ctx context.Context, args CmdArgs) *exec.Cmd {
    shell := args.Shell[0]
    cargs := append(args.Shell[1:], args.Cmd)

    // #nosec G204
    c := exec.CommandContext(ctx, shell, cargs...)
    c.Dir = args.WorkingDir
    c.Env = args.Env
    c.Stdout = args.Stdout
    c.Stderr = args.Stderr

    if args.IsInteractive {
        c.Stdin = os.Stdin
    }

    return c
}
```

Call site stays the same except for exported field name:

```go
cmdHandler := func(c context.Context) *exec.Cmd {
    return CreateCommand(c, CmdArgs{
        Shell:         task.Shell,
        Env:           fn.ToEnviron(envStore),
        Cmd:           cmd.Text,
        WorkingDir:    task.Dir,
        Stdout:        args.Stdout.WithPrefix(logPrefix),
        Stderr:        args.Stderr.WithPrefix(logPrefix),
        IsInteractive: task.Interactive,
    })
}
```

This lets you evolve command execution independently of task resolution/UI.

### 3. Split `RunTask` into orchestration helpers

`RunTask` currently wires everything (resolution, pipeline build, watcher, goroutines). A small decomposition will make it easier to follow and test without changing behavior:

```go
func (r *Resolver) RunTask(ctx context.Context, name string) error {
    rt, err := r.GetTask(name)
    if err != nil {
        return err
    }

    lw := &writer.LogWriter{Writer: os.Stderr}
    pipeline, err := r.buildPipeline(rt, lw)
    if err != nil {
        return err
    }

    if rt.Watch == nil || !rt.Watch.Enabled {
        return pipeline.Start(ctx)
    }

    return r.runWithWatcher(ctx, rt, pipeline)
}

func (r *Resolver) buildPipeline(rt *ResolvedTask, lw *writer.LogWriter) (*executor.Pipeline, error) {
    steps, err := r.createSteps(rt, createCommandGroupArgs{
        Stdout: lw,
        Stderr: lw,
    })
    if err != nil {
        return nil, err
    }
    return executor.NewPipeline(slog.Default(), steps), nil
}

func (r *Resolver) runWithWatcher(ctx context.Context, rt *ResolvedTask, pipeline *executor.Pipeline) error {
    var wg sync.WaitGroup
    watch, err := watcher.NewWatcher(ctx, watcher.WatcherArgs{
        WatchDirs:            rt.Watch.Dirs,
        IgnoreDirs:           rt.Watch.IgnoreDirs,
        WatchExtensions:      rt.Watch.Extensions,
        IgnoreExtensions:     rt.Watch.IgnoreExtensions,
        IgnoreList:           watcher.DefaultIgnoreList,
        Interactive:          rt.Interactive,
        ShouldLogWatchEvents: false,
    })
    if err != nil {
        return err
    }

    wg.Add(1)
    go func() {
        defer wg.Done()
        <-ctx.Done()
        watch.Close()
    }()

    wg.Add(1)
    go func() {
        defer wg.Done()
        if err := pipeline.Start(ctx); err != nil {
            slog.Error("starting command", "err", err)
        }
        slog.Debug("final executor start finished")
    }()

    wg.Add(1)
    go func() {
        defer wg.Done()
        <-ctx.Done()
        pipeline.Stop()
        slog.Debug("2. context cancelled")
    }()

    wg.Add(1)
    go func() {
        defer wg.Done()
        watch.Watch(ctx)
        slog.Debug("3. watcher closed")
    }()

    counter := 0
    for ev := range watch.GetEvents() {
        slog.Debug("received", "event", ev)
        counter++
        slog.Info(fmt.Sprintf("[RELOADING (%d)] due changes in %s", counter, ev.Name))
    }

    wg.Wait()
    return nil
}
```

This keeps `RunTask` at a high-level “resolve → build → run/watch” flow and makes the watcher logic testable in isolation.

These extractions don’t change behavior but reduce the responsibilities of `task.go`, improving cohesion and making future changes (e.g. different UI or execution strategy) easier to implement.
</issue_to_address>

### Comment 3
<location> `pkg/runfile/resolver/env.go:90` </location>
<code_context>
					lazyEvalMap[k] = exec.CommandContext(ctx, shell[0], append(shell[1:], envEvalScript)...)
</code_context>

<issue_to_address>
**security (go.lang.security.audit.dangerous-exec-command):** Detected non-static command inside Command. Audit the input to 'exec.Command'. If unverified user data can reach this call site, this is a code injection vulnerability. A malicious actor can inject a malicious script to execute arbitrary code.

*Source: opengrep*
</issue_to_address>

Hi @nxtcoder17! 👋

Thanks for trying out Sourcery by commenting with @sourcery-ai review! 🚀

Install the sourcery-ai bot to get automatic code reviews on every pull request ✨

Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread cmd/run/main.go
@@ -0,0 +1,461 @@
package resolver
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider decomposing this large task.go file by extracting UI/printing helpers, generic command creation, and watcher orchestration into dedicated helpers/files to keep task resolution logic focused and easier to follow.

You can keep the current behavior intact while shaving off a lot of cognitive load by extracting a few clearly separable concerns into small helpers / files.

1. Extract UI / printing helpers out of this file

isDarkTheme, printCommand, padString, longestLineLen are UI-only and couple this file to termenv, lipgloss, chroma, term. You can move them into a ui.go (or display.go) and only expose a narrow interface:

// ui.go
package resolver

import (
    "bytes"
    "fmt"
    "os"
    "strings"
    "sync"

    "github.com/alecthomas/chroma/v2/quick"
    "github.com/charmbracelet/lipgloss"
    "github.com/muesli/termenv"
    "github.com/nxtcoder17/runfile/pkg/writer"
    "golang.org/x/term"
)

var (
    darkThemeOnce   sync.Once
    darkThemeResult bool
)

func isDarkTheme() bool {
    darkThemeOnce.Do(func() {
        darkThemeResult = termenv.NewOutput(os.Stdout).HasDarkBackground()
    })
    return darkThemeResult
}

func PrintCommand(w *writer.LogWriter, prefix, lang, cmd string) {
    borderColor := "#4388cc"
    if !isDarkTheme() {
        borderColor = "#3d5485"
    }

    myBorder := lipgloss.Border{
        Top:         "-+",
        Bottom:      "-+",
        Left:        "|",
        Right:       "|",
        TopLeft:     "+",
        TopRight:    "+",
        BottomLeft:  "+",
        BottomRight: "+",
    }

    s := lipgloss.NewStyle().
        Border(myBorder).
        BorderForeground(lipgloss.Color(borderColor)).
        PaddingLeft(1).
        PaddingRight(1)
    defer s.UnsetBorderStyle()
    defer s.UnsetPadding()

    width := 0
    if term.IsTerminal(0) {
        width, _, _ = term.GetSize(0)
    }

    hlCode := new(bytes.Buffer)
    colorscheme := "catppuccin-macchiato"
    if !isDarkTheme() {
        colorscheme = "xcode"
    }

    cmdStr := strings.TrimSpace(cmd)
    longestLen := longestLineLen(cmdStr) + len(prefix) + 2
    if err := quick.Highlight(hlCode, cmdStr, lang, "terminal16m", colorscheme); err != nil {
        fmt.Fprintf(w, "%s %s\n", writer.GetStyledPrefix(prefix), cmdStr)
        return
    }

    if width > 0 && longestLen >= width-2 {
        s = s.Width(width - 2)
    }

    fmt.Fprintf(w, "\r\033[K%s%s\n", padString(s.Render(hlCode.String()), prefix), s.UnsetBorderStyle())
}

func longestLineLen(str string) int {
    sp := strings.Split(str, "\n")
    l := len(sp[0])
    for i := 1; i < len(sp); i++ {
        if len(sp[i]) > l {
            l = len(sp[i])
        }
    }
    return l
}

func padString(str string, withPrefix string) string {
    sp := strings.Split(str, "\n")
    for i := range sp {
        if i == 0 {
            sp[i] = fmt.Sprintf("%s %s", writer.GetStyledPrefix(withPrefix), sp[i])
            continue
        }
        sp[i] = fmt.Sprintf("%s %s", strings.Repeat(" ", len(withPrefix)+2), sp[i])
    }
    return strings.Join(sp, "\n")
}

Then in task.go you just call:

preHook := func(c context.Context) error {
    if task.Silent || task.Interactive {
        return nil
    }
    str := strings.TrimSpace(cmd.Text)

    lang := "bash"
    if len(task.Shell) > 0 {
        lang = task.Shell[0]
    }
    PrintCommand(args.Stderr, logPrefix, lang, str)
    return nil
}

This keeps createSteps focused on execution structure instead of styling details.

2. Move CreateCommand to a command.go closer to execution concerns

CreateCommand is generic and not task-specific. Pull it into its own file so task.go doesn’t mix resolution + UI + low-level command wiring:

// command.go
package resolver

import (
    "context"
    "io"
    "os"
    "os/exec"
)

type CmdArgs struct {
    Shell      []string
    Env        []string // [key=value,...]
    WorkingDir string

    Cmd string

    IsInteractive bool
    Stdout        io.Writer
    Stderr        io.Writer
}

func CreateCommand(ctx context.Context, args CmdArgs) *exec.Cmd {
    shell := args.Shell[0]
    cargs := append(args.Shell[1:], args.Cmd)

    // #nosec G204
    c := exec.CommandContext(ctx, shell, cargs...)
    c.Dir = args.WorkingDir
    c.Env = args.Env
    c.Stdout = args.Stdout
    c.Stderr = args.Stderr

    if args.IsInteractive {
        c.Stdin = os.Stdin
    }

    return c
}

Call site stays the same except for exported field name:

cmdHandler := func(c context.Context) *exec.Cmd {
    return CreateCommand(c, CmdArgs{
        Shell:         task.Shell,
        Env:           fn.ToEnviron(envStore),
        Cmd:           cmd.Text,
        WorkingDir:    task.Dir,
        Stdout:        args.Stdout.WithPrefix(logPrefix),
        Stderr:        args.Stderr.WithPrefix(logPrefix),
        IsInteractive: task.Interactive,
    })
}

This lets you evolve command execution independently of task resolution/UI.

3. Split RunTask into orchestration helpers

RunTask currently wires everything (resolution, pipeline build, watcher, goroutines). A small decomposition will make it easier to follow and test without changing behavior:

func (r *Resolver) RunTask(ctx context.Context, name string) error {
    rt, err := r.GetTask(name)
    if err != nil {
        return err
    }

    lw := &writer.LogWriter{Writer: os.Stderr}
    pipeline, err := r.buildPipeline(rt, lw)
    if err != nil {
        return err
    }

    if rt.Watch == nil || !rt.Watch.Enabled {
        return pipeline.Start(ctx)
    }

    return r.runWithWatcher(ctx, rt, pipeline)
}

func (r *Resolver) buildPipeline(rt *ResolvedTask, lw *writer.LogWriter) (*executor.Pipeline, error) {
    steps, err := r.createSteps(rt, createCommandGroupArgs{
        Stdout: lw,
        Stderr: lw,
    })
    if err != nil {
        return nil, err
    }
    return executor.NewPipeline(slog.Default(), steps), nil
}

func (r *Resolver) runWithWatcher(ctx context.Context, rt *ResolvedTask, pipeline *executor.Pipeline) error {
    var wg sync.WaitGroup
    watch, err := watcher.NewWatcher(ctx, watcher.WatcherArgs{
        WatchDirs:            rt.Watch.Dirs,
        IgnoreDirs:           rt.Watch.IgnoreDirs,
        WatchExtensions:      rt.Watch.Extensions,
        IgnoreExtensions:     rt.Watch.IgnoreExtensions,
        IgnoreList:           watcher.DefaultIgnoreList,
        Interactive:          rt.Interactive,
        ShouldLogWatchEvents: false,
    })
    if err != nil {
        return err
    }

    wg.Add(1)
    go func() {
        defer wg.Done()
        <-ctx.Done()
        watch.Close()
    }()

    wg.Add(1)
    go func() {
        defer wg.Done()
        if err := pipeline.Start(ctx); err != nil {
            slog.Error("starting command", "err", err)
        }
        slog.Debug("final executor start finished")
    }()

    wg.Add(1)
    go func() {
        defer wg.Done()
        <-ctx.Done()
        pipeline.Stop()
        slog.Debug("2. context cancelled")
    }()

    wg.Add(1)
    go func() {
        defer wg.Done()
        watch.Watch(ctx)
        slog.Debug("3. watcher closed")
    }()

    counter := 0
    for ev := range watch.GetEvents() {
        slog.Debug("received", "event", ev)
        counter++
        slog.Info(fmt.Sprintf("[RELOADING (%d)] due changes in %s", counter, ev.Name))
    }

    wg.Wait()
    return nil
}

This keeps RunTask at a high-level “resolve → build → run/watch” flow and makes the watcher logic testable in isolation.

These extractions don’t change behavior but reduce the responsibilities of task.go, improving cohesion and making future changes (e.g. different UI or execution strategy) easier to implement.

// #nosec G204 - This is intentional: env vars with sh/bash keys are meant to
// execute shell commands defined in the Runfile. The Runfile is trusted
// user-provided configuration, similar to Makefiles or shell scripts.
lazyEvalMap[k] = exec.CommandContext(ctx, shell[0], append(shell[1:], envEvalScript)...)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security (go.lang.security.audit.dangerous-exec-command): Detected non-static command inside Command. Audit the input to 'exec.Command'. If unverified user data can reach this call site, this is a code injection vulnerability. A malicious actor can inject a malicious script to execute arbitrary code.

Source: opengrep

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 security issue, 2 other issues, and left some high level feedback:

Security issues:

  • Detected non-static command inside Command. Audit the input to 'exec.Command'. If unverified user data can reach this call site, this is a code injection vulnerability. A malicious actor can inject a malicious script to execute arbitrary code. (link)

General comments:

  • In resolver.Load, env := rf.Env can be nil, so calling maps.Copy(env, included.Env) risks a panic; initialize env with make(map[string]any) before copying into it.
  • When normalizing dotenv paths for included tasks in resolver.Load, you currently join task.DotEnv against runfileDir; these paths should likely be relative to includedDir so that includes resolve their own dotenv files correctly.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `resolver.Load`, `env := rf.Env` can be `nil`, so calling `maps.Copy(env, included.Env)` risks a panic; initialize `env` with `make(map[string]any)` before copying into it.
- When normalizing dotenv paths for included tasks in `resolver.Load`, you currently join `task.DotEnv` against `runfileDir`; these paths should likely be relative to `includedDir` so that includes resolve their own dotenv files correctly.

## Individual Comments

### Comment 1
<location> `cmd/run/main.go:107-116` </location>
<code_context>
+			// generateShellCompletion(ctx, c.Root().Writer, runfilePath)
 		},

 		Commands: []*cli.Command{
 			{
-				Name:    "shell:completion",
</code_context>

<issue_to_address>
**issue (bug_risk):** CLI now only runs the first task argument and drops support for suffix flags, which may break existing usage.

Previously you could run multiple tasks in one command (e.g. `run build test`) and use flags like `-p/--parallel` and `-w/--watch` even when they appeared after task names via post‑processing `c.Args().Slice()`. The new `RunTask` only uses `args[0]` and the suffix‑flag logic is gone, so extra tasks and trailing flags are now silently ignored. If this change isn’t deliberate, either (a) loop over all task args and call `RunTask` for each, and (b) restore suffix‑flag handling, or (c) explicitly reject multiple task args with a clear error to avoid silent behavior changes.
</issue_to_address>

### Comment 2
<location> `pkg/runfile/resolver/task.go:92` </location>
<code_context>
+	}, nil
+}
+
+func (r *Resolver) RunTask(ctx context.Context, name string) error {
+	rt, err := r.GetTask(name)
+	if err != nil {
</code_context>

<issue_to_address>
**issue (complexity):** Consider reducing this file’s responsibilities by extracting helpers for watching, step creation, UI printing, and command parsing, and by splitting the code into smaller focused files.

You can reduce the complexity of this change without losing any functionality by extracting a few focused helpers and untangling some responsibilities.

### 1. Extract watcher orchestration out of `RunTask`

Right now `RunTask` is doing *both* orchestration and low-level watcher wiring. You can keep semantics but make the core path much easier to read by pushing the watcher logic into a helper:

```go
func (r *Resolver) RunTask(ctx context.Context, name string) error {
	rt, err := r.GetTask(name)
	if err != nil {
		return err
	}

	lw := &writer.LogWriter{Writer: os.Stderr}

	steps, err := r.createSteps(rt, createCommandGroupArgs{
		Stdout: lw,
		Stderr: lw,
	})
	if err != nil {
		return err
	}

	pipeline := executor.NewPipeline(slog.Default(), steps)

	if rt.Watch == nil || !rt.Watch.Enabled {
		return pipeline.Start(ctx)
	}

	return runWithWatch(ctx, rt, pipeline)
}
```

```go
// keep this in the same package, but in a smaller focused unit (e.g. watch.go)
func runWithWatch(ctx context.Context, rt *ResolvedTask, pipeline *executor.Pipeline) error {
	var wg sync.WaitGroup

	watch, err := watcher.NewWatcher(ctx, watcher.WatcherArgs{
		WatchDirs:            rt.Watch.Dirs,
		IgnoreDirs:           rt.Watch.IgnoreDirs,
		WatchExtensions:      rt.Watch.Extensions,
		IgnoreExtensions:     rt.Watch.IgnoreExtensions,
		IgnoreList:           watcher.DefaultIgnoreList,
		Interactive:          rt.Interactive,
		ShouldLogWatchEvents: false,
	})
	if err != nil {
		return err
	}

	wg.Add(1)
	go func() {
		defer wg.Done()
		<-ctx.Done()
		watch.Close()
	}()

	wg.Add(1)
	go func() {
		defer wg.Done()
		if err := pipeline.Start(ctx); err != nil {
			slog.Error("starting command", "err", err)
		}
	}()

	wg.Add(1)
	go func() {
		defer wg.Done()
		<-ctx.Done()
		pipeline.Stop()
	}()

	wg.Add(1)
	go func() {
		defer wg.Done()
		watch.Watch(ctx)
	}()

	counter := 0
	for ev := range watch.GetEvents() {
		counter++
		slog.Info(fmt.Sprintf("[RELOADING (%d)] due changes in %s", counter, ev.Name))
	}

	wg.Wait()
	return nil
}
```

This keeps all behavior (goroutines, WaitGroup, logging, reload loop) but makes `RunTask` itself trivial.

---

### 2. Split `createSteps` into small helpers

`createSteps` currently mixes env resolution, recursion, step construction, and UI pre-hooks. You can keep the public signature but delegate the details:

```go
func (r *Resolver) createSteps(task *ResolvedTask, args createCommandGroupArgs) ([]executor.Step, error) {
	taskTrail := append(append([]string{}, args.TaskTrail...), task.Name)
	slog.Debug("creating command groups", "task.name", task.Name, "task.trail", taskTrail)

	var steps []executor.Step
	for _, cmd := range task.Commands {
		envStore, err := r.resolveEnv(task, cmd, args.Env)
		if err != nil {
			return nil, err
		}

		if cmd.IsRunTarget {
			sub, err := r.createSubStepsForRunTarget(cmd.Text, envStore, task, args, taskTrail)
			if err != nil {
				return nil, err
			}
			steps = append(steps, sub...)
			continue
		}

		step, err := r.buildShellStep(task, cmd, envStore, args, taskTrail)
		if err != nil {
			return nil, err
		}
		steps = append(steps, step)
	}

	slog.Debug("created command groups", "len", len(steps))
	return steps, nil
}
```

Then define focused helpers:

```go
func (r *Resolver) resolveEnv(task *ResolvedTask, cmd *Command, parentEnv map[string]string) (map[string]string, error) {
	envStore := fn.MapMerge(r.Env, parentEnv)

	if task.Env != nil {
		if err := parseEnvInto(context.TODO(), envStore, task.Env); err != nil {
			return nil, err
		}
	}
	if cmd.Env != nil {
		if err := parseEnvInto(context.TODO(), envStore, cmd.Env); err != nil {
			return nil, err
		}
	}
	return envStore, nil
}
```

```go
func (r *Resolver) createSubStepsForRunTarget(
	target string,
	envStore map[string]string,
	parent *ResolvedTask,
	args createCommandGroupArgs,
	taskTrail []string,
) ([]executor.Step, error) {
	rt, err := r.GetTask(target)
	if err != nil {
		return nil, err
	}

	substeps, err := r.createSteps(rt, createCommandGroupArgs{
		Stdout:    args.Stdout,
		Stderr:    args.Stderr,
		Env:       envStore,
		Silent:    parent.Silent || rt.Silent,
		TaskTrail: taskTrail,
	})
	if err != nil {
		return nil, err
	}

	return []executor.Step{{
		SubSteps: substeps,
		Parallel: rt.Parallel,
	}}, nil
}
```

```go
func (r *Resolver) buildShellStep(
	task *ResolvedTask,
	cmd *Command,
	envStore map[string]string,
	args createCommandGroupArgs,
	taskTrail []string,
) (executor.Step, error) {
	logPrefix := strings.Join(taskTrail, "")
	step := executor.Step{Parallel: task.Parallel}

	cmdHandler := func(c context.Context) *exec.Cmd {
		return CreateCommand(c, CmdArgs{
			Shell:      task.Shell,
			Env:        fn.ToEnviron(envStore),
			Cmd:        cmd.Text,
			WorkingDir: task.Dir,
			Stdout:     args.Stdout.WithPrefix(logPrefix),
			Stderr:     args.Stderr.WithPrefix(logPrefix),
		})
	}

	preHook := func(c context.Context) error {
		if task.Silent || task.Interactive {
			return nil
		}
		str := strings.TrimSpace(cmd.Text)
		lang := "bash"
		if len(task.Shell) > 0 {
			lang = task.Shell[0]
		}
		printCommand(args.Stderr, logPrefix, lang, str)
		return nil
	}

	if task.Interactive {
		step.Commands = append(step.Commands,
			executor.NewInteractiveShellCommand(cmdHandler).AddPreHook(preHook),
		)
	} else {
		step.Commands = append(step.Commands,
			executor.NewShellCommand(cmdHandler).AddPreHook(preHook),
		)
	}

	return step, nil
}
```

This keeps behavior identical but makes each concern testable and easier to reason about.

---

### 3. Decouple UI rendering via a small interface

`printCommand` is UI-only but is pulled into `createSteps` via the pre-hook. You can hide this behind an interface so execution and rendering are separated:

```go
type CommandPrinter interface {
	PrintCommand(prefix, lang, cmd string)
}
```

Extend `createCommandGroupArgs`:

```go
type createCommandGroupArgs struct {
	Stdout    *writer.LogWriter
	Stderr    *writer.LogWriter
	Env       map[string]string
	Silent    bool
	TaskTrail []string

	Printer CommandPrinter
}
```

Then in `buildShellStep`:

```go
preHook := func(c context.Context) error {
	if task.Silent || task.Interactive || args.Printer == nil {
		return nil
	}
	str := strings.TrimSpace(cmd.Text)
	lang := "bash"
	if len(task.Shell) > 0 {
		lang = task.Shell[0]
	}
	args.Printer.PrintCommand(logPrefix, lang, str)
	return nil
}
```

And implement the concrete printer in a separate file (e.g. `print.go`) that owns `printCommand`, `padString`, `longestLineLen`, and `isDarkTheme`:

```go
type chromaPrinter struct {
	w *writer.LogWriter
}

func (p *chromaPrinter) PrintCommand(prefix, lang, cmd string) {
	printCommand(p.w, prefix, lang, cmd)
}
```

Then wire it in `RunTask`:

```go
printer := &chromaPrinter{w: lw}
steps, err := r.createSteps(rt, createCommandGroupArgs{
	Stdout:  lw,
	Stderr:  lw,
	Printer: printer,
})
```

This keeps the same output but isolates all chroma/lipgloss/termenv code.

---

### 4. Avoid JSON round‑trip in `parseCommands`

You can keep semantics while removing the JSON marshal/unmarshal complexity by mapping the `map[string]any` directly into `spec.CommandObject`:

```go
func parseCommands(commands []any) ([]*Command, error) {
	result := make([]*Command, 0, len(commands))

	for _, command := range commands {
		switch c := command.(type) {
		case string:
			if c == "" {
				continue
			}
			result = append(result, &Command{Text: c})
		case map[string]any:
			jsonCmd := toCommandObject(c)

			cmd := &Command{Env: jsonCmd.Env}
			switch {
			case jsonCmd.Run != nil:
				if *jsonCmd.Run == "" {
					return nil, errors.New("empty run target")
				}
				cmd.Text = *jsonCmd.Run
				cmd.IsRunTarget = true
			case jsonCmd.Command != nil:
				if *jsonCmd.Command == "" {
					return nil, errors.New("empty command")
				}
				cmd.Text = *jsonCmd.Command
			default:
				return nil, errors.New("either 'run' or 'cmd' key, must be specified when setting command in json format")
			}
			result = append(result, cmd)
		default:
			return nil, errors.New("invalid command type, must be either a string or an object")
		}
	}
	return result, nil
}
```

```go
func toCommandObject(m map[string]any) spec.CommandObject {
	var out spec.CommandObject

	if v, ok := m["run"]; ok {
		if s, ok := v.(string); ok {
			out.Run = &s
		}
	}
	if v, ok := m["cmd"]; ok {
		if s, ok := v.(string); ok {
			out.Command = &s
		}
	}
	if v, ok := m["env"]; ok {
		if env, ok := v.(map[string]any); ok {
			out.Env = env
		}
	}

	return out
}
```

This reduces hidden allocations and error sources while preserving the same behavior and error messages.

---

### 5. Move low-level types into smaller files

Without changing any APIs, consider just splitting the file by responsibility:

- `cmd.go`: `CmdArgs`, `CreateCommand`
- `steps.go`: `createCommandGroupArgs`, `createSteps` and its helpers
- `print.go`: `isDarkTheme`, `printCommand`, `padString`, `longestLineLen`, `CommandPrinter` implementation
- `watch.go`: `runWithWatch`

This keeps the current design but avoids the “god file” issue, making local reasoning cheaper without changing runtime behavior.
</issue_to_address>

### Comment 3
<location> `pkg/runfile/resolver/env.go:90` </location>
<code_context>
					lazyEvalMap[k] = exec.CommandContext(ctx, shell[0], append(shell[1:], envEvalScript)...)
</code_context>

<issue_to_address>
**security (go.lang.security.audit.dangerous-exec-command):** Detected non-static command inside Command. Audit the input to 'exec.Command'. If unverified user data can reach this call site, this is a code injection vulnerability. A malicious actor can inject a malicious script to execute arbitrary code.

*Source: opengrep*
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread cmd/run/main.go
Comment on lines 107 to +116
Commands: []*cli.Command{
{
Name: "shell:completion",
Usage: "<bash|zsh|fish|ps>",
Suggest: true,
Name: "shell:completion",
Usage: "[shell]",
EnableShellCompletion: false,
Action: func(ctx context.Context, c *cli.Command) error {
fmt.Printf("args: (%d)\n", c.NArg())
if c.NArg() != 1 {
return fmt.Errorf("needs argument one of [bash,zsh,fish,ps]")
if c.NArg() == 0 {
for _, shell := range []string{"fish", "bash", "zsh", "powershell"} {
fmt.Fprintf(c.Writer, "%s\n", shell)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): CLI now only runs the first task argument and drops support for suffix flags, which may break existing usage.

Previously you could run multiple tasks in one command (e.g. run build test) and use flags like -p/--parallel and -w/--watch even when they appeared after task names via post‑processing c.Args().Slice(). The new RunTask only uses args[0] and the suffix‑flag logic is gone, so extra tasks and trailing flags are now silently ignored. If this change isn’t deliberate, either (a) loop over all task args and call RunTask for each, and (b) restore suffix‑flag handling, or (c) explicitly reject multiple task args with a clear error to avoid silent behavior changes.

}, nil
}

func (r *Resolver) RunTask(ctx context.Context, name string) error {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider reducing this file’s responsibilities by extracting helpers for watching, step creation, UI printing, and command parsing, and by splitting the code into smaller focused files.

You can reduce the complexity of this change without losing any functionality by extracting a few focused helpers and untangling some responsibilities.

1. Extract watcher orchestration out of RunTask

Right now RunTask is doing both orchestration and low-level watcher wiring. You can keep semantics but make the core path much easier to read by pushing the watcher logic into a helper:

func (r *Resolver) RunTask(ctx context.Context, name string) error {
	rt, err := r.GetTask(name)
	if err != nil {
		return err
	}

	lw := &writer.LogWriter{Writer: os.Stderr}

	steps, err := r.createSteps(rt, createCommandGroupArgs{
		Stdout: lw,
		Stderr: lw,
	})
	if err != nil {
		return err
	}

	pipeline := executor.NewPipeline(slog.Default(), steps)

	if rt.Watch == nil || !rt.Watch.Enabled {
		return pipeline.Start(ctx)
	}

	return runWithWatch(ctx, rt, pipeline)
}
// keep this in the same package, but in a smaller focused unit (e.g. watch.go)
func runWithWatch(ctx context.Context, rt *ResolvedTask, pipeline *executor.Pipeline) error {
	var wg sync.WaitGroup

	watch, err := watcher.NewWatcher(ctx, watcher.WatcherArgs{
		WatchDirs:            rt.Watch.Dirs,
		IgnoreDirs:           rt.Watch.IgnoreDirs,
		WatchExtensions:      rt.Watch.Extensions,
		IgnoreExtensions:     rt.Watch.IgnoreExtensions,
		IgnoreList:           watcher.DefaultIgnoreList,
		Interactive:          rt.Interactive,
		ShouldLogWatchEvents: false,
	})
	if err != nil {
		return err
	}

	wg.Add(1)
	go func() {
		defer wg.Done()
		<-ctx.Done()
		watch.Close()
	}()

	wg.Add(1)
	go func() {
		defer wg.Done()
		if err := pipeline.Start(ctx); err != nil {
			slog.Error("starting command", "err", err)
		}
	}()

	wg.Add(1)
	go func() {
		defer wg.Done()
		<-ctx.Done()
		pipeline.Stop()
	}()

	wg.Add(1)
	go func() {
		defer wg.Done()
		watch.Watch(ctx)
	}()

	counter := 0
	for ev := range watch.GetEvents() {
		counter++
		slog.Info(fmt.Sprintf("[RELOADING (%d)] due changes in %s", counter, ev.Name))
	}

	wg.Wait()
	return nil
}

This keeps all behavior (goroutines, WaitGroup, logging, reload loop) but makes RunTask itself trivial.


2. Split createSteps into small helpers

createSteps currently mixes env resolution, recursion, step construction, and UI pre-hooks. You can keep the public signature but delegate the details:

func (r *Resolver) createSteps(task *ResolvedTask, args createCommandGroupArgs) ([]executor.Step, error) {
	taskTrail := append(append([]string{}, args.TaskTrail...), task.Name)
	slog.Debug("creating command groups", "task.name", task.Name, "task.trail", taskTrail)

	var steps []executor.Step
	for _, cmd := range task.Commands {
		envStore, err := r.resolveEnv(task, cmd, args.Env)
		if err != nil {
			return nil, err
		}

		if cmd.IsRunTarget {
			sub, err := r.createSubStepsForRunTarget(cmd.Text, envStore, task, args, taskTrail)
			if err != nil {
				return nil, err
			}
			steps = append(steps, sub...)
			continue
		}

		step, err := r.buildShellStep(task, cmd, envStore, args, taskTrail)
		if err != nil {
			return nil, err
		}
		steps = append(steps, step)
	}

	slog.Debug("created command groups", "len", len(steps))
	return steps, nil
}

Then define focused helpers:

func (r *Resolver) resolveEnv(task *ResolvedTask, cmd *Command, parentEnv map[string]string) (map[string]string, error) {
	envStore := fn.MapMerge(r.Env, parentEnv)

	if task.Env != nil {
		if err := parseEnvInto(context.TODO(), envStore, task.Env); err != nil {
			return nil, err
		}
	}
	if cmd.Env != nil {
		if err := parseEnvInto(context.TODO(), envStore, cmd.Env); err != nil {
			return nil, err
		}
	}
	return envStore, nil
}
func (r *Resolver) createSubStepsForRunTarget(
	target string,
	envStore map[string]string,
	parent *ResolvedTask,
	args createCommandGroupArgs,
	taskTrail []string,
) ([]executor.Step, error) {
	rt, err := r.GetTask(target)
	if err != nil {
		return nil, err
	}

	substeps, err := r.createSteps(rt, createCommandGroupArgs{
		Stdout:    args.Stdout,
		Stderr:    args.Stderr,
		Env:       envStore,
		Silent:    parent.Silent || rt.Silent,
		TaskTrail: taskTrail,
	})
	if err != nil {
		return nil, err
	}

	return []executor.Step{{
		SubSteps: substeps,
		Parallel: rt.Parallel,
	}}, nil
}
func (r *Resolver) buildShellStep(
	task *ResolvedTask,
	cmd *Command,
	envStore map[string]string,
	args createCommandGroupArgs,
	taskTrail []string,
) (executor.Step, error) {
	logPrefix := strings.Join(taskTrail, " ≫ ")
	step := executor.Step{Parallel: task.Parallel}

	cmdHandler := func(c context.Context) *exec.Cmd {
		return CreateCommand(c, CmdArgs{
			Shell:      task.Shell,
			Env:        fn.ToEnviron(envStore),
			Cmd:        cmd.Text,
			WorkingDir: task.Dir,
			Stdout:     args.Stdout.WithPrefix(logPrefix),
			Stderr:     args.Stderr.WithPrefix(logPrefix),
		})
	}

	preHook := func(c context.Context) error {
		if task.Silent || task.Interactive {
			return nil
		}
		str := strings.TrimSpace(cmd.Text)
		lang := "bash"
		if len(task.Shell) > 0 {
			lang = task.Shell[0]
		}
		printCommand(args.Stderr, logPrefix, lang, str)
		return nil
	}

	if task.Interactive {
		step.Commands = append(step.Commands,
			executor.NewInteractiveShellCommand(cmdHandler).AddPreHook(preHook),
		)
	} else {
		step.Commands = append(step.Commands,
			executor.NewShellCommand(cmdHandler).AddPreHook(preHook),
		)
	}

	return step, nil
}

This keeps behavior identical but makes each concern testable and easier to reason about.


3. Decouple UI rendering via a small interface

printCommand is UI-only but is pulled into createSteps via the pre-hook. You can hide this behind an interface so execution and rendering are separated:

type CommandPrinter interface {
	PrintCommand(prefix, lang, cmd string)
}

Extend createCommandGroupArgs:

type createCommandGroupArgs struct {
	Stdout    *writer.LogWriter
	Stderr    *writer.LogWriter
	Env       map[string]string
	Silent    bool
	TaskTrail []string

	Printer CommandPrinter
}

Then in buildShellStep:

preHook := func(c context.Context) error {
	if task.Silent || task.Interactive || args.Printer == nil {
		return nil
	}
	str := strings.TrimSpace(cmd.Text)
	lang := "bash"
	if len(task.Shell) > 0 {
		lang = task.Shell[0]
	}
	args.Printer.PrintCommand(logPrefix, lang, str)
	return nil
}

And implement the concrete printer in a separate file (e.g. print.go) that owns printCommand, padString, longestLineLen, and isDarkTheme:

type chromaPrinter struct {
	w *writer.LogWriter
}

func (p *chromaPrinter) PrintCommand(prefix, lang, cmd string) {
	printCommand(p.w, prefix, lang, cmd)
}

Then wire it in RunTask:

printer := &chromaPrinter{w: lw}
steps, err := r.createSteps(rt, createCommandGroupArgs{
	Stdout:  lw,
	Stderr:  lw,
	Printer: printer,
})

This keeps the same output but isolates all chroma/lipgloss/termenv code.


4. Avoid JSON round‑trip in parseCommands

You can keep semantics while removing the JSON marshal/unmarshal complexity by mapping the map[string]any directly into spec.CommandObject:

func parseCommands(commands []any) ([]*Command, error) {
	result := make([]*Command, 0, len(commands))

	for _, command := range commands {
		switch c := command.(type) {
		case string:
			if c == "" {
				continue
			}
			result = append(result, &Command{Text: c})
		case map[string]any:
			jsonCmd := toCommandObject(c)

			cmd := &Command{Env: jsonCmd.Env}
			switch {
			case jsonCmd.Run != nil:
				if *jsonCmd.Run == "" {
					return nil, errors.New("empty run target")
				}
				cmd.Text = *jsonCmd.Run
				cmd.IsRunTarget = true
			case jsonCmd.Command != nil:
				if *jsonCmd.Command == "" {
					return nil, errors.New("empty command")
				}
				cmd.Text = *jsonCmd.Command
			default:
				return nil, errors.New("either 'run' or 'cmd' key, must be specified when setting command in json format")
			}
			result = append(result, cmd)
		default:
			return nil, errors.New("invalid command type, must be either a string or an object")
		}
	}
	return result, nil
}
func toCommandObject(m map[string]any) spec.CommandObject {
	var out spec.CommandObject

	if v, ok := m["run"]; ok {
		if s, ok := v.(string); ok {
			out.Run = &s
		}
	}
	if v, ok := m["cmd"]; ok {
		if s, ok := v.(string); ok {
			out.Command = &s
		}
	}
	if v, ok := m["env"]; ok {
		if env, ok := v.(map[string]any); ok {
			out.Env = env
		}
	}

	return out
}

This reduces hidden allocations and error sources while preserving the same behavior and error messages.


5. Move low-level types into smaller files

Without changing any APIs, consider just splitting the file by responsibility:

  • cmd.go: CmdArgs, CreateCommand
  • steps.go: createCommandGroupArgs, createSteps and its helpers
  • print.go: isDarkTheme, printCommand, padString, longestLineLen, CommandPrinter implementation
  • watch.go: runWithWatch

This keeps the current design but avoids the “god file” issue, making local reasoning cheaper without changing runtime behavior.

// #nosec G204 - This is intentional: env vars with sh/bash keys are meant to
// execute shell commands defined in the Runfile. The Runfile is trusted
// user-provided configuration, similar to Makefiles or shell scripts.
lazyEvalMap[k] = exec.CommandContext(ctx, shell[0], append(shell[1:], envEvalScript)...)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security (go.lang.security.audit.dangerous-exec-command): Detected non-static command inside Command. Audit the input to 'exec.Command'. If unverified user data can reach this call site, this is a code injection vulnerability. A malicious actor can inject a malicious script to execute arbitrary code.

Source: opengrep

@nxtcoder17
Copy link
Copy Markdown
Owner Author

@sourcery-ai review

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 security issue, 1 other issue, and left some high level feedback:

Security issues:

  • Detected non-static command inside Command. Audit the input to 'exec.Command'. If unverified user data can reach this call site, this is a code injection vulnerability. A malicious actor can inject a malicious script to execute arbitrary code. (link)

General comments:

  • In parseDotEnvFilesInto, the dotenv file handle is only closed on the success path; if godotenv.Parse returns an error the file is never closed—wrap f.Close() in a defer right after os.Open to avoid leaking file descriptors.
  • The interactive command path in NewInteractiveShellCommand ignores context cancellation for the I/O pumps (the io.Copy goroutines); consider wiring cancellation through by breaking the copies on ctx.Done() or closing the PTY on cancel so interactive sessions terminate promptly when the task is cancelled.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `parseDotEnvFilesInto`, the dotenv file handle is only closed on the success path; if `godotenv.Parse` returns an error the file is never closed—wrap `f.Close()` in a `defer` right after `os.Open` to avoid leaking file descriptors.
- The interactive command path in `NewInteractiveShellCommand` ignores context cancellation for the I/O pumps (the `io.Copy` goroutines); consider wiring cancellation through by breaking the copies on `ctx.Done()` or closing the PTY on cancel so interactive sessions terminate promptly when the task is cancelled.

## Individual Comments

### Comment 1
<location> `pkg/runfile/resolver/task.go:385-391` </location>
<code_context>
+		// INFO: env var overrides with args.Env takes priority
+		envStore := fn.MapMerge(r.Env, args.Env)
+
+		if task.Env != nil {
+			if err := parseEnvInto(context.TODO(), envStore, task.Env); err != nil {
+				return nil, err
+			}
+		}
+
+		if cmd.Env != nil {
+			if err := parseEnvInto(context.TODO(), envStore, cmd.Env); err != nil {
+				return nil, err
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Consider threading the caller’s context into `parseEnvInto` instead of using `context.TODO()` to respect cancellation.

Using `context.TODO()` here means env-eval scripts won’t respect pipeline cancellation and may continue running indefinitely. If `createSteps` can access a real context (e.g., from `RunTask`), pass it through to `parseEnvInto` so env shell evaluation is properly cancelled or timed out.

Suggested implementation:

```golang
		// INFO: env var overrides with args.Env takes priority
		envStore := fn.MapMerge(r.Env, args.Env)

		if task.Env != nil {
			if err := parseEnvInto(ctx, envStore, task.Env); err != nil {
				return nil, err
			}
		}

		if cmd.Env != nil {
			if err := parseEnvInto(ctx, envStore, cmd.Env); err != nil {
				return nil, err
			}
		}

```

To fully implement the suggestion and respect cancellation:

1. Update the function containing this code (likely `createSteps`) to accept a `context.Context` parameter, for example:
   - From: `func (r *Resolver) createSteps(args *CreateStepsArgs) ([]*Step, error)`
   - To:   `func (r *Resolver) createSteps(ctx context.Context, args *CreateStepsArgs) ([]*Step, error)`

2. Thread the caller’s context into this function from its callers:
   - Wherever `createSteps` (or this function) is called, pass through the existing context (e.g. from `RunTask(ctx, ...)`):
     - From: `steps, err := r.createSteps(args)`
     - To:   `steps, err := r.createSteps(ctx, args)`

3. Ensure `context` is imported at the top of `pkg/runfile/resolver/task.go` if it is not already:
   - `import "context"`

After these changes, `parseEnvInto` will receive the real caller context and env-eval execution will honor cancellation and timeouts.
</issue_to_address>

### Comment 2
<location> `pkg/runfile/resolver/env.go:92` </location>
<code_context>
					lazyEvalMap[k] = exec.CommandContext(ctx, shell[0], append(shell[1:], envEvalScript)...)
</code_context>

<issue_to_address>
**security (go.lang.security.audit.dangerous-exec-command):** Detected non-static command inside Command. Audit the input to 'exec.Command'. If unverified user data can reach this call site, this is a code injection vulnerability. A malicious actor can inject a malicious script to execute arbitrary code.

*Source: opengrep*
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +385 to +391
if task.Env != nil {
if err := parseEnvInto(context.TODO(), envStore, task.Env); err != nil {
return nil, err
}
}

if cmd.Env != nil {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Consider threading the caller’s context into parseEnvInto instead of using context.TODO() to respect cancellation.

Using context.TODO() here means env-eval scripts won’t respect pipeline cancellation and may continue running indefinitely. If createSteps can access a real context (e.g., from RunTask), pass it through to parseEnvInto so env shell evaluation is properly cancelled or timed out.

Suggested implementation:

		// INFO: env var overrides with args.Env takes priority
		envStore := fn.MapMerge(r.Env, args.Env)

		if task.Env != nil {
			if err := parseEnvInto(ctx, envStore, task.Env); err != nil {
				return nil, err
			}
		}

		if cmd.Env != nil {
			if err := parseEnvInto(ctx, envStore, cmd.Env); err != nil {
				return nil, err
			}
		}

To fully implement the suggestion and respect cancellation:

  1. Update the function containing this code (likely createSteps) to accept a context.Context parameter, for example:

    • From: func (r *Resolver) createSteps(args *CreateStepsArgs) ([]*Step, error)
    • To: func (r *Resolver) createSteps(ctx context.Context, args *CreateStepsArgs) ([]*Step, error)
  2. Thread the caller’s context into this function from its callers:

    • Wherever createSteps (or this function) is called, pass through the existing context (e.g. from RunTask(ctx, ...)):
      • From: steps, err := r.createSteps(args)
      • To: steps, err := r.createSteps(ctx, args)
  3. Ensure context is imported at the top of pkg/runfile/resolver/task.go if it is not already:

    • import "context"

After these changes, parseEnvInto will receive the real caller context and env-eval execution will honor cancellation and timeouts.

Comment thread pkg/runfile/resolver/env.go
@nxtcoder17 nxtcoder17 merged commit 65f8ccf into master Feb 9, 2026
1 check failed
@nxtcoder17 nxtcoder17 deleted the refactor branch February 9, 2026 18:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants